Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ emits ~60k `msg_sent` events, so never render events as DOM.
- Alpenglow `standstill_ms` (default 2 s, paper value much longer): with no new finalization for that long a node re-broadcasts its votes and certificates. Without it a healed partition deadlocks, because votes are sent once and both halves have already voted.
- A validator's stake counts once towards a Skip certificate even if it casts Skip and SkipFallback (`pool.rs` tracks `skip_voters`).
- Rotor relays are sampled per shred with replacement, proportional to stake; a permutation made every node a relay in small clusters and offline low-stake nodes starved slices.
- `Target::StakePct` picks random nodes that fit under the target and, if far short, the *smallest* remaining node; the old "any node" overshoot could take a 45% whale offline for a 5% target.
- `Target::StakePct` picks random nodes that fit under the target and, if far short, the *smallest* remaining node only when that lands closer to the target; the old "any node" overshoot took a 95% whale offline for a 19% target (liveness proptest seed 9684). Realized offline stake can therefore be *below* the target.
- An Alpenglow leader stops producing slices for a slot once its Pool holds a Skip certificate for it, and receivers do not count a block in a skipped slot as "landed" for the hero tx; otherwise a tx could be buried in a dead block with no retry.

## Tests

Expand Down
8 changes: 7 additions & 1 deletion crates/sim-core/src/protocol/alpenglow/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,8 @@ impl Node {
return;
}
ctx.emit(TraceEvent::BlockReceived { node: self.id, slot: meta.slot, hash: meta.hash });
if meta.txs.contains(&HERO_TX) {
// A block in an already-skipped slot can never be notarized; do not treat its txs as landed.
if meta.txs.contains(&HERO_TX) && !self.pool.is_skipped(meta.slot) {
self.hero_block = Some(meta.hash);
self.leader.included.insert(HERO_TX);
}
Expand Down Expand Up @@ -588,6 +589,11 @@ impl Node {
}

fn produce_slice(&mut self, slot: u64, slice: u32, ctx: &mut Ctx<Alpenglow>) {
// A slot the cluster has already closed with a Skip certificate is dead: producing more
// slices for it would only bury transactions in a block nobody will notarize.
if self.pool.is_skipped(slot) {
return;
}
let ap = ctx.params.alpenglow.clone();
let me = self.id;
let ws = ctx.schedule.window_start(slot);
Expand Down
46 changes: 44 additions & 2 deletions crates/sim-core/src/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,14 @@ impl Target {
}
}
// If we fell far short (very coarse stakes), allow one overshoot with the
// smallest remaining node, so a 5% target can never take a 45% whale offline.
// smallest remaining node, but only if that lands closer to the goal than
// stopping short: a 19% target must never take a 95% whale offline.
if (acc as f64) < 0.9 * goal as f64 {
if let Some(id) = ids.iter().filter(|id| !out.contains(id)).min_by_key(|id| vs.stake(**id)) {
out.push(*id);
let with = acc + vs.stake(*id);
if with.abs_diff(goal) < acc.abs_diff(goal) {
out.push(*id);
}
}
}
out.sort_unstable();
Expand Down Expand Up @@ -546,3 +550,41 @@ impl Default for TowerParams {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn resolve(stakes: Vec<u64>, pct: f64, seed: u64) -> (Vec<NodeId>, u64, u64) {
let vs = ValidatorSet::new(stakes);
let sched = LeaderSchedule::generate(&vs, &mut Rng::seed(seed), 40, 4);
let nodes = Target::StakePct { stake_pct: pct }.resolve(&vs, &sched, &mut Rng::seed(seed));
let sum = vs.sum(nodes.iter().copied());
(nodes, sum, vs.total)
}

/// Seed 9684 of the liveness proptest: one validator holds 94.6% of the stake. A 19.1%
/// offline target must fall short (5.4%) rather than take the whale (100%) offline.
#[test]
fn stake_pct_never_overshoots_with_a_whale() {
let stakes = vec![946159, 13846, 8897, 8408, 4323, 3266, 3077, 3048, 2540, 2364, 2047, 2027];
for seed in 1..20 {
let (nodes, sum, total) = resolve(stakes.clone(), 0.191, seed);
assert!(!nodes.contains(&0), "whale taken offline (seed {seed})");
assert!(sum <= (0.191 * total as f64) as u64, "overshoot: {sum} of {total} (seed {seed})");
}
}

/// With a mainnet-like distribution the realized offline stake stays close to the target.
#[test]
fn stake_pct_lands_near_target_for_pareto_stakes() {
let spec = ValidatorSpec::default();
for seed in 1..40 {
let vs = spec.build(&mut Rng::seed(seed));
let (_, sum, total) = resolve(vs.stakes.clone(), 0.25, seed);
let frac = sum as f64 / total as f64;
assert!(frac <= 0.25 + 1e-9, "overshoot {frac} (seed {seed})");
assert!(frac >= 0.18, "fell too short: {frac} (seed {seed})");
}
}
}
Binary file modified web/e2e/screenshots/compare-leader-down.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading