Skip to content

feat(v2:replay): basic epoch voter states - #1770

Draft
yewman wants to merge 12 commits into
king/snapshot-epoch-stakesfrom
harnew/v2-stakes-mvp
Draft

feat(v2:replay): basic epoch voter states#1770
yewman wants to merge 12 commits into
king/snapshot-epoch-stakesfrom
harnew/v2-stakes-mvp

Conversation

@yewman

@yewman yewman commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

WIP

yewman added 2 commits July 28, 2026 05:21
Open-addressed, linear-probed, fixed-capacity Pubkey -> V map.
Capacity is a comptime power of two; callers size to >= 2x expected
occupancy. Pubkey.ZEROES is the empty-slot sentinel. No delete —
rebuild via init().

Intended to live inline in extern structs (e.g. EpochVoters), so V
must be extern-compatible.

Wired into v2/lib/collections.zig; unit tests cover insert/get,
missing keys, zero-pubkey rejection, duplicate overwrite, pointer
stability across inserts, and random fill at 50% load.
Replay-owned stakes types.

- EpochVoters: extern struct, 2000-entry frozen per-epoch snapshot
  of admitted voters (vote_pk, stake, commission_bps) plus a
  FixedPubkeyMap<u16, 4096> side index and precomputed total_stake.
  Serves the sol_get_epoch_stake syscall (O(1)) and the clock
  sysvar timestamp update (linear scan in lockstep with LiveVoters).
- LiveVoter (24 B) / LiveVoters (48 KB): dense per-block live vote
  state, positionally aligned with EpochVoters.entries. Kind is
  { unpopulated, update, invalidate }.
- ReplayStakes: single-owner container. epoch_voters + one LiveVoters
  per BlockPool slot + boot_epoch. No allocations after init.
  Provisional shape: v2 convention is one top-level shared region
  per collection (BlockPool style); the wrapper is expected to
  dissolve into standalone regions. epoch_voters and live_voters
  themselves are durable. boot_epoch is a provisional hard-stop
  anchor, deleted once epoch-boundary rotation state lands.

MAX_ALPENGLOW_VOTE_ACCOUNTS = 2000 (SIMD-0357). Map cap = 4096 to
respect FixedPubkeyMap's pow2 / 2x-occupancy invariants.

Tests: EpochVoters init/lookup, LiveVoters.reset.
@github-project-automation github-project-automation Bot moved this to 🏗 In progress in Sig Jul 28, 2026
yewman added 7 commits July 28, 2026 10:41
- EpochVoters.loadFromVersionedEpochStakes(entry, memory_base):
  copies { pubkey, stake } into entries, sorts desc, builds the
  by_vote_pk index, sums total_stake. commission_bps stays zero —
  the trimmed VersionedEpochStakes.VoteAccountEntry only carries
  { pubkey, stake } and no reader consumes commission today.
  Errors on over-cap input (MAX_ALPENGLOW_VOTE_ACCOUNTS).
- ReplayStakes.loadFromSnapshot(root_slot, manifest, memory_base):
  derives boot_epoch via EpochSchedule.getEpoch, locates the
  matching VersionedEpochStakes entry, delegates. Errors if no
  entry matches the boot epoch.
- v2/services/replay.zig: allocates a ReplayStakes on the FBA,
  init(), calls loadFromSnapshot after bootstrap. Reads root_slot
  via a second (non-blocking) getSlotBlocking since bootstrap has
  already crossed the release barrier.
- v2/tests/replay/main.zig: fixture manifest gains a minimal
  VersionedEpochStakes entry (epoch 0, empty voters) so the loader
  succeeds. Downstream reads stay no-ops until fixtures grow real
  stakes data.

Two new unit tests cover the sort+index and the over-cap reject.
- ReplayStakes.onBlockCreate(parent, child): explicit @memcpy on
  the byte view (`std.mem.asBytes`) rather than struct assignment.
  Struct-assign of a ~48 KB LiveVoters value passes through a stack
  temporary in Debug mode; the byte-level memcpy stays in-place and
  keeps the tx-exec call stack healthy.
- v2/services/replay.zig: thread ?*ReplayStakes through
  insertFecSet -> setChildTreeBlockRefs -> setChildBlockRef. Fire
  onBlockCreate at the two block_pool.create() sites in
  setChildBlockRef — case (b) slot boundary and case (c)
  fork/equivocation. Case (a) has no parent BlockRef; case (d)
  reuses the parent's BlockRef unchanged (same-slot canonical
  extension keeps writing the same slot).
- Production caller in serviceMain passes the real replay_stakes.
  Bootstrap's synthetic root insertFecSet passes null — root has no
  parent BlockRef so setChildBlockRef early-outs anyway. Existing
  test callers also pass null.

Test: onBlockCreate mutates the parent slot, clones, mutates the
child, asserts parent is untouched.
`solGetEpochStake(epoch_voters, ?*const Pubkey) u64` — pure
implementation of SIMD-0133 semantics:
- null arg -> epoch_voters.total_stake (total active stake).
- non-null -> epoch_voters.stakeOf(pk), which returns 0 on miss
  (agave parity — not-a-vote-account / non-existent -> 0).

VM-side concerns (compute-meter charging, VM memory translation of
the pubkey argument) are outside the primitive; the SVM tile will
apply them and call this function with a validated ?*const Pubkey.

Not yet reachable at runtime — v2 has no SVM interpreter. When the
exec tile grows one, register this as the sol_get_epoch_stake
handler with the current EpochVoters bound on the invoke context.

Test covers null / hit / miss.
`stakeWeightedTimestamp(epoch_voters, live_voters, current_slot,
slot_duration_ns, epoch_start, drift) ?i64` — pure implementation
of agave's `calculate_stake_weighted_timestamp`:
1. Project each `.update` voter's `last_vote_timestamp` to
   current_slot via `elapsed_secs = slots * slot_duration_ns / 1e9`.
2. Sort projections asc, walk accumulating stake, return the
   projection where cumulative first exceeds total / 2 — the
   stake-weighted median.
3. If epoch_start is provided, bound the median forward /
   backward by drift.slow_pct / drift.fast_pct of the poh estimate
   offset since the epoch start.
Returns null iff no voter has a live `.update` row with non-zero
stake.

Also adds:
- TimestampDrift extern struct with SIMD-0133 defaults
  (fast=25%, slow=150%).
- EpochStartTimestamp anchor struct.

Not yet reachable at runtime — v2 has no per-slot sysvar update
path. When one exists it calls this each slot with the current
fork's LiveVoters; the returned i64 is written to
Clock.unix_timestamp.

Scratch buffer: 2000 (estimate, stake) pairs, ~32 KB on the
caller's stack. Declared locally in the pure function; safe because
we call it top-of-stack, not from a deep tx-exec chain (unlike the
onBlockCreate copy that hit the Debug stack-temporary trap).

Tests: no-signal returns null; three-voter median crosses at the
right stake bucket; slow-drift clamp forward to epoch_start +
poh_off + slow_bound.
`foldLandedVote(epoch_voters, live_voters, vote_pk, last_vote_slot,
last_vote_timestamp)` — the primitive the committer path invokes
per landed vote tx.

- Look up the vote pubkey in `by_vote_pk`. Miss: no-op (post-
  Alpenglow SIMD-0357 caps admitted voters at top-2000; non-
  admitted votes land at the tx layer but contribute nothing to
  the timestamp aggregate).
- Hit: overwrite the positional row with the new .update state
  (last_vote_slot, last_vote_timestamp, kind = .update).

Not yet reachable at runtime — v2 has no vote-program execution.
When the exec tile grows one, the committer path decodes each
landed vote ix (accounts + payload) and calls this once with the
extracted values.

Vote-account deletion (the .invalidate transition) is not modelled.

Tests: fresh admitted voter -> .update; second fold overwrites in
place; non-admitted voter -> no-op across the whole array.
- ReplayStakes gains `first_slot_of_next_epoch: Slot`, cached at
  boot from `epoch_schedule.getFirstSlotInEpoch(boot_epoch + 1)`.
- ReplayStakes.ensureSlotInBootEpoch(slot) returns
  error.EpochBoundaryNotYetImplemented for slot >= boundary. Both
  fields are provisional scaffolding, deleted when boundary
  derivation lands.
- Wired into setChildBlockRef case (b) — the point at which a new
  BlockRef is allocated for a slot different from the parent. Case
  (c) is same-slot fork; case (d) is same-slot canonical extension;
  case (a) has no BlockRef. Only case (b) can cross an epoch.
- insertFecSet error set widened to include the new variant.

Fixture: v2/tests/replay/main.zig now sets `epoch_schedule = .INIT`
instead of leaving it zero-initialized (which gave a degenerate
epoch=0 for every slot). Boot epoch for slot 410009999 under
EpochSchedule.INIT resolves to 961; the VersionedEpochStakes entry
epoch is aligned to the derived boot_epoch so the loader still
matches.

Test: ensureSlotInBootEpoch passes in-epoch slots (500, 599 for a
boot into epoch 5); trips on the boundary (600) and beyond.
Three multi-primitive tests exercising the stakes flow at the
module level (no runtime plumbing — that path is covered by the
bbt-replay black-box test):

- boot -> solGetEpochStake -> foldLandedVote ->
  stakeWeightedTimestamp: synthesize a VersionedEpochStakes entry
  with three admitted voters at stakes 100/250/650, load, query
  the syscall for null/hit/miss, fold three landed votes plus one
  unadmitted stranger, verify the stake-weighted median crosses
  at the top-staked voter's timestamp.
- fork of depth 3 (root -> A -> B -> C): fold at each hop, memcpy
  via onBlockCreate, verify each descendant carries all prior
  state and root remains untouched.
- sibling forks (parent -> left, parent -> right with disjoint
  vote sets): verify left / right / parent stay isolated after
  the two memcpy branches.

Deferred: a real snapshot round-trip (needs a fixture with real
stakes data) and syscall conformance vectors (needs a v2 SVM
interpreter).
@yewman
yewman force-pushed the harnew/v2-stakes-mvp branch from 1679713 to 4e100f4 Compare July 28, 2026 10:42
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.34629% with 32 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
v2/lib/replay/stakes.zig 96.37% 14 Missing ⚠️
v2/services/replay.zig 62.50% 12 Missing ⚠️
v2/lib/collections/pubkey_map.zig 94.11% 6 Missing ⚠️
Files with missing lines Coverage Δ
v2/lib/replay.zig 0.00% <ø> (ø)
v2/lib/solana/snapshot.zig 96.52% <100.00%> (+0.41%) ⬆️
v2/lib/collections/pubkey_map.zig 94.11% <94.11%> (ø)
v2/services/replay.zig 37.19% <62.50%> (-0.37%) ⬇️
v2/lib/replay/stakes.zig 96.37% <96.37%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

yewman added 3 commits July 28, 2026 12:14
…hStakes parse

- VoteAccountEntry gains `commission_bps: u16` (+ pad).
- VersionedEpochStakes.read peeks the first bytes of each
  vote-account data blob before discarding the rest, extracts the
  commission field via voteStateCommissionBps, and stores it on
  the entry.
- voteStateCommissionBps: pure helper. Reads the u32 discriminant
  and dispatches to the v1_14_11 / v3 layout (u8 percentage at
  offset 68, scaled by 100) or the v4 layout (u16
  inflation_rewards_commission_bps at offset 132, SIMD-0185).
  Returns 0 for unknown discriminants, uninitialized states, or
  blobs too short for the encoded variant.
- EpochVoters.loadFromVersionedEpochStakes now copies
  `src.commission_bps` into the row instead of writing 0.

Extracting at parse time (one linear read pass over the vote data
we would have discarded anyway) is strictly cheaper than a second
pass over accounts_db at boot, and keeps the offset knowledge in
one place with the rest of the VoteStateVersions layout facts.

Tests (in snapshot.zig): v1_14_11 / v3 percentage decode
(42 -> 4200 bps, 7 -> 700); v4 bps direct decode (750);
malformed and out-of-range inputs return 0 rather than aborting.
Make it explicit in the doc comments that LiveVoter, LiveVoters,
foldLandedVote, stakeWeightedTimestamp, and EpochStartTimestamp
serve the pre-Alpenglow Clock.unix_timestamp derivation
(stake-weighted median of vote timestamps landed as vote txs).

Under Alpenglow, individual vote messages leave the block (they
travel over the votor / consensus lane instead), so replay never
observes them during block execution. Clock.unix_timestamp is
written from the block footer's block_producer_time_nanos
instead (SIMD-0363; agave Bank::update_clock_from_footer in
agave/runtime/src/bank.rs), and none of the LiveVoters machinery
is invoked.

No functional change; call sites and shape unchanged. The
Alpenglow footer-timestamp path is a separate future workstream
belonging to the block-execution flow, not to the stakes module.
@yewman
yewman force-pushed the harnew/v2-stakes-mvp branch from f2f41fa to 1a9651f Compare July 30, 2026 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🏗 In progress

Development

Successfully merging this pull request may close these issues.

1 participant