feat(aggregation): score recursive aggregation through a per-aggregator subnet window - #613
feat(aggregation): score recursive aggregation through a per-aggregator subnet window#613MegaRedHand wants to merge 22 commits into
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.
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.--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.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.
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: candidates whose windowed selection was not viable and fell back to the full committee set. A persistently rising value 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 docsbuildsAttestationDataattestation_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.