Background
#21694 wires GLOAS head selection (getHeadGloas) onto the incremental indexedWeightStore: votes are maintained in a directVotes index per attestation (IndexVote/RemoveVote), and weights are computed lazily by a subtree DFS per GetWeight. This is correct in steady state (differential test vs the full-scan store) and far faster than the previous O(V) full-scan — but it diverges from the canonical fork-choice design and keeps a per-attestation maintenance cost on the hot path.
#21698 removed that per-attestation index maintenance pre-GLOAS (where its result was never read). Under GLOAS it returns, because the index is maintained on every GLOAS attestation and getHeadGloas now reads it. #21694 trims it (allocation-free RemoveVote, no per-vote getCheckpointState), but RemoveVote is still an O(E) scan per attestation under the fork-choice lock.
What Prysm and Lighthouse do (verified on glamsterdam-devnet-5)
Both use the proto-array / doubly-linked-tree model:
- Per attestation: O(1), no weight work — just record the validator's vote (current/next root + payload-present). Prysm
ProcessAttestation updates f.votes[index]; Lighthouse process_attestation sets vote.next_root / next_payload_present.
- Per head: delta propagation over a maintained per-node weight tree. Lighthouse
apply_score_changes computes per-validator deltas from vote changes and applies them to node.weight, propagated up — O(nodes + changed votes). Prysm applyWeightChangesConsensusNode / ...PayloadNode recompute weight = balance + Σ children bottom-up.
(root, payload_status) handled per node. Lighthouse: each ProtoNode carries empty_delta / full_delta and attestation_score(payload_status). Prysm: a two-tier tree of consensus Node → EMPTY/FULL PayloadNode.
Net: votes stored per validator (cheap), per-node cumulative weights materialized incrementally; head = follow best-descendant; the debug dump is free.
Proposal
Adopt the delta model for Caplin GLOAS fork choice:
- Stop maintaining the
directVotes index per attestation; keep votes in latestMessages (already O(1)/attestation).
- Maintain a per-node cumulative weight (with Empty/Full payload-status variants), updated by deltas computed from
latestMessages changes at head time and propagated over the filtered tree — O(nodes + changed votes).
getHeadGloas follows best-descendant over the maintained weights.
Benefits
Cost / notes
References
Background
#21694 wires GLOAS head selection (
getHeadGloas) onto the incrementalindexedWeightStore: votes are maintained in adirectVotesindex per attestation (IndexVote/RemoveVote), and weights are computed lazily by a subtree DFS perGetWeight. This is correct in steady state (differential test vs the full-scan store) and far faster than the previous O(V) full-scan — but it diverges from the canonical fork-choice design and keeps a per-attestation maintenance cost on the hot path.#21698 removed that per-attestation index maintenance pre-GLOAS (where its result was never read). Under GLOAS it returns, because the index is maintained on every GLOAS attestation and
getHeadGloasnow reads it. #21694 trims it (allocation-freeRemoveVote, no per-votegetCheckpointState), butRemoveVoteis still an O(E) scan per attestation under the fork-choice lock.What Prysm and Lighthouse do (verified on
glamsterdam-devnet-5)Both use the proto-array / doubly-linked-tree model:
ProcessAttestationupdatesf.votes[index]; Lighthouseprocess_attestationsetsvote.next_root/next_payload_present.apply_score_changescomputes per-validator deltas from vote changes and applies them tonode.weight, propagated up — O(nodes + changed votes). PrysmapplyWeightChangesConsensusNode/...PayloadNoderecomputeweight = balance + Σ childrenbottom-up.(root, payload_status)handled per node. Lighthouse: eachProtoNodecarriesempty_delta/full_deltaandattestation_score(payload_status). Prysm: a two-tier tree of consensusNode→EMPTY/FULLPayloadNode.Net: votes stored per validator (cheap), per-node cumulative weights materialized incrementally; head = follow best-descendant; the debug dump is free.
Proposal
Adopt the delta model for Caplin GLOAS fork choice:
directVotesindex per attestation; keep votes inlatestMessages(already O(1)/attestation).latestMessageschanges at head time and propagated over the filtered tree — O(nodes + changed votes).getHeadGloasfollows best-descendant over the maintained weights.Benefits
IndexVote/RemoveVoteon the hot path) under GLOAS as well as pre-GLOAS — finishing what cl/phase1/forkchoice: don't maintain GLOAS indexed weight store pre-GLOAS #21698 started.GetWeightsubtree DFS with O(nodes) delta propagation.f.weights/ForkNodesgap (/eth/v1/debug/fork_choiceis currently empty under GLOAS becausegetHeadGloasdoesn't populatef.weights).seedFromLatestMessages+ theseededflag) added in cl/phase1/forkchoice: use the incremental indexed weight store for the GLOAS head #21694: deriving weights from the continuously-maintainedlatestMessagesleaves no cold index to seed.Cost / notes
latestMessages+ on-demand scoring, not a proto-array-style node tree; this adds a maintained weight tree.References
cl/phase1/forkchoice/weight_store_indexed.go,get_head.go(getHeadGloas),on_attestation.go(setLatestMessage).glamsterdam-devnet-5:beacon-chain/forkchoice/doubly-linked-tree/{gloas.go,forkchoice.go,node.go}.glamsterdam-devnet-5:consensus/proto_array/src/{proto_array.rs,proto_array_fork_choice.rs}.