mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
306 lines
11 KiB
Rust
306 lines
11 KiB
Rust
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
//! Who gets asked, and how hard the answer has to be.
|
|
//!
|
|
//! Two numbers decide a round before any vote is cast: the peers sampled, and
|
|
//! the threshold their answers are read against. Both are derived rather than
|
|
//! configured at the point of use, and both have a range they must stay inside —
|
|
//! a brightness that ran away would let one peer own the sample, and a θ outside
|
|
//! its band would make every round either unanimous or free.
|
|
|
|
use lux_consensus::{FpcSelector, Luminance, NodeID, PhotonSampler, QuasarConfig};
|
|
|
|
fn peer(n: u8) -> NodeID {
|
|
NodeID([n; 20])
|
|
}
|
|
|
|
// -------------------------------------------------------------- the threshold band
|
|
|
|
/// A θ range outside (0,1) is replaced by the standard band, not accepted.
|
|
///
|
|
/// The bound matters because θ is the fraction of a committee that has to agree:
|
|
/// θ = 0 makes every round a quorum and θ = 1 demands unanimity from a sample
|
|
/// that is allowed to miss peers. A selector built from a mis-parsed config must
|
|
/// land on the standard band rather than on whichever of those two the bad value
|
|
/// happened to be.
|
|
#[test]
|
|
fn a_theta_outside_the_band_falls_back_to_the_standard_one() {
|
|
let seed = [0u8; 32];
|
|
let standard = (0.5, 0.8);
|
|
|
|
for bad_min in [0.0, 1.0, -1.0, 2.0] {
|
|
assert_eq!(
|
|
FpcSelector::new(bad_min, 0.8, seed).range(),
|
|
standard,
|
|
"theta_min = {bad_min} was taken as given"
|
|
);
|
|
}
|
|
|
|
// A maximum that is not above the minimum is not a band at all.
|
|
for bad_max in [0.5, 0.4, 0.0, 1.5] {
|
|
assert_eq!(
|
|
FpcSelector::new(0.5, bad_max, seed).range(),
|
|
standard,
|
|
"theta_max = {bad_max} was taken as given"
|
|
);
|
|
}
|
|
|
|
// A band inside the bounds is kept, so the fallback is a repair and not a
|
|
// policy that overrides the operator.
|
|
assert_eq!(FpcSelector::new(0.6, 0.9, seed).range(), (0.6, 0.9));
|
|
}
|
|
|
|
/// Every θ the selector draws lands inside its own band, at every phase. The
|
|
/// threshold is `ceil(θ·k)`, so a θ that escaped the band would put the quorum
|
|
/// above k — unreachable — or at zero, where no votes are a quorum.
|
|
#[test]
|
|
fn every_drawn_threshold_stays_inside_the_band_and_the_committee() {
|
|
let selector = FpcSelector::default();
|
|
let (lo, hi) = selector.range();
|
|
let k = 21;
|
|
|
|
for phase in 0..64u64 {
|
|
let theta = selector.theta(phase);
|
|
assert!(
|
|
theta >= lo && theta <= hi,
|
|
"phase {phase} drew theta {theta}, outside [{lo}, {hi}]"
|
|
);
|
|
|
|
let alpha = selector.select_threshold(phase, k);
|
|
assert!(
|
|
alpha >= 1 && alpha <= k,
|
|
"phase {phase} asked for {alpha} of {k}"
|
|
);
|
|
assert!(
|
|
alpha > k / 2,
|
|
"phase {phase} asked for {alpha} of {k}, which is not a majority"
|
|
);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------- brightness
|
|
|
|
/// Brightness is clamped at both ends, and the clamp is what keeps sampling
|
|
/// weight bounded: the multipliers compound, so an unclamped run of successes
|
|
/// grows without limit until one peer owns every sample, and an unclamped run of
|
|
/// failures decays to zero, where a peer can never be picked again and can never
|
|
/// recover. Both are permanent, and both are one long run away.
|
|
#[test]
|
|
fn brightness_is_clamped_at_both_ends() {
|
|
let config = QuasarConfig::default();
|
|
let mut lux = Luminance::new(&config);
|
|
let node = peer(1);
|
|
|
|
for _ in 0..500 {
|
|
lux.illuminate(&node, true);
|
|
}
|
|
assert_eq!(
|
|
lux.lux(&node),
|
|
config.max_luminance,
|
|
"a long run of successes escaped the cap"
|
|
);
|
|
|
|
for _ in 0..500 {
|
|
lux.illuminate(&node, false);
|
|
}
|
|
assert_eq!(
|
|
lux.lux(&node),
|
|
config.min_luminance,
|
|
"a long run of failures dimmed past the floor"
|
|
);
|
|
|
|
// The floor is above zero, so a peer that has failed everything is still
|
|
// sampleable and can climb back.
|
|
assert!(config.min_luminance > 0.0);
|
|
lux.illuminate(&node, true);
|
|
assert!(
|
|
lux.lux(&node) > config.min_luminance,
|
|
"a peer at the floor could not recover"
|
|
);
|
|
}
|
|
|
|
/// An unseen peer reads at the base rather than at zero, so a validator that has
|
|
/// not been sampled yet is not thereby unsampleable. Brightness is the ratio to
|
|
/// that base, which is what makes it comparable across configs.
|
|
#[test]
|
|
fn an_unseen_peer_reads_at_the_base() {
|
|
let config = QuasarConfig::default();
|
|
let mut lux = Luminance::new(&config);
|
|
|
|
assert_eq!(lux.lux(&peer(1)), config.base_luminance);
|
|
assert_eq!(lux.brightness(&peer(1)), 1.0);
|
|
assert_eq!(lux.node_count(), 0, "asking about a peer tracked it");
|
|
assert_eq!(lux.total_luminance(), 0.0);
|
|
|
|
lux.illuminate(&peer(1), true);
|
|
assert_eq!(lux.node_count(), 1);
|
|
assert!(lux.brightness(&peer(1)) > 1.0);
|
|
assert_eq!(lux.total_luminance(), lux.lux(&peer(1)));
|
|
}
|
|
|
|
/// The default tracker is the one the default config describes. The two used to
|
|
/// be written out separately, which is how a tracker ended up with a floor its
|
|
/// config did not have — the same drift the config presets are stated as diffs
|
|
/// to avoid.
|
|
#[test]
|
|
fn the_default_tracker_reads_the_default_config() {
|
|
let config = QuasarConfig::default();
|
|
let mut from_default = Luminance::default();
|
|
let mut from_config = Luminance::new(&config);
|
|
let node = peer(1);
|
|
|
|
for step in 0..40 {
|
|
let success = step % 3 != 0;
|
|
from_default.illuminate(&node, success);
|
|
from_config.illuminate(&node, success);
|
|
assert_eq!(
|
|
from_default.lux(&node),
|
|
from_config.lux(&node),
|
|
"the default tracker and the default config disagree after {step} rounds"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------- sampling
|
|
|
|
/// A sample is distinct peers, and never more of them than there are. Asking for
|
|
/// more than the whole set returns the whole set once — a sampler that padded to
|
|
/// k by repeating would count one validator several times toward a quorum.
|
|
#[test]
|
|
fn a_sample_is_distinct_and_never_larger_than_the_set() {
|
|
let config = QuasarConfig::default();
|
|
let peers: Vec<NodeID> = (1..=5).map(peer).collect();
|
|
let sampler = PhotonSampler::new(peers.clone(), &config);
|
|
|
|
for k in 0..=8 {
|
|
let drawn = sampler.sample(k);
|
|
assert_eq!(
|
|
drawn.len(),
|
|
k.min(peers.len()),
|
|
"asking for {k} of {} returned {}",
|
|
peers.len(),
|
|
drawn.len()
|
|
);
|
|
|
|
let mut distinct = drawn.clone();
|
|
distinct.sort();
|
|
distinct.dedup();
|
|
assert_eq!(
|
|
distinct.len(),
|
|
drawn.len(),
|
|
"a peer was sampled twice at k={k}"
|
|
);
|
|
|
|
for d in &drawn {
|
|
assert!(peers.contains(d), "a peer nobody registered was sampled");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An empty set samples nobody. A sampler that returned a placeholder here would
|
|
/// hand a round a committee of one imaginary peer.
|
|
#[test]
|
|
fn an_empty_set_samples_nobody() {
|
|
let sampler = PhotonSampler::new(Vec::new(), &QuasarConfig::default());
|
|
assert!(sampler.sample(5).is_empty());
|
|
}
|
|
|
|
/// Sampling is deterministic: the same peers in the same state draw the same
|
|
/// committee. Every node runs this selection independently, so a sampler that
|
|
/// consulted a hash map's iteration order or a clock would give two honest nodes
|
|
/// different committees for one round.
|
|
#[test]
|
|
fn the_same_state_draws_the_same_committee() {
|
|
let config = QuasarConfig::default();
|
|
let peers: Vec<NodeID> = (1..=7).map(peer).collect();
|
|
let mut sampler = PhotonSampler::new(peers, &config);
|
|
|
|
sampler.update_luminance(&peer(3), true);
|
|
sampler.update_luminance(&peer(5), false);
|
|
|
|
let first = sampler.sample(4);
|
|
for _ in 0..8 {
|
|
assert_eq!(
|
|
sampler.sample(4),
|
|
first,
|
|
"two draws from one state disagreed"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A peer joins once and leaves completely. A duplicate entry would be a second
|
|
/// seat in every sample it appears in, which is the same fault a duplicate
|
|
/// registration is, one layer up.
|
|
#[test]
|
|
fn a_peer_joins_once_and_leaves_completely() {
|
|
let config = QuasarConfig::default();
|
|
let mut sampler = PhotonSampler::new(vec![peer(1)], &config);
|
|
|
|
sampler.add_peer(peer(2));
|
|
sampler.add_peer(peer(2));
|
|
assert_eq!(sampler.sample(9).len(), 2, "a peer was added twice");
|
|
|
|
sampler.remove_peer(&peer(1));
|
|
let remaining = sampler.sample(9);
|
|
assert_eq!(remaining, vec![peer(2)]);
|
|
|
|
// Removing a peer that was never there is not a change.
|
|
sampler.remove_peer(&peer(200));
|
|
assert_eq!(sampler.sample(9), remaining);
|
|
}
|
|
|
|
/// The sampler's own tracker is what its weights are read from, so an update
|
|
/// through the sampler is visible in the tracker it hands back. Two trackers
|
|
/// would mean the weights that select a committee and the weights an operator
|
|
/// inspects were different numbers.
|
|
#[test]
|
|
fn the_sampler_updates_the_tracker_it_reports() {
|
|
let config = QuasarConfig::default();
|
|
let mut sampler = PhotonSampler::new(vec![peer(1), peer(2)], &config);
|
|
|
|
sampler.update_luminance(&peer(1), true);
|
|
|
|
assert!(
|
|
sampler.luminance().brightness(&peer(1)) > 1.0,
|
|
"an update through the sampler did not reach its tracker"
|
|
);
|
|
assert_eq!(sampler.luminance().brightness(&peer(2)), 1.0);
|
|
}
|
|
|
|
/// When every weight has collapsed to zero the sampler still returns a
|
|
/// committee. Weighted selection has nothing to weigh here — the scores are all
|
|
/// zero and the choice between them is arbitrary — and the alternative to
|
|
/// falling back is returning no peers, which stalls the round forever. A
|
|
/// liveness floor, not a preference.
|
|
///
|
|
/// It is reachable only from a configuration that permits total darkness: a
|
|
/// floor of zero and a failure multiplier that reaches it. The default config
|
|
/// cannot get here, which is why the floor is above zero there.
|
|
#[test]
|
|
fn a_committee_is_still_drawn_when_every_weight_has_collapsed() {
|
|
let dark = QuasarConfig {
|
|
base_luminance: 100.0,
|
|
min_luminance: 0.0,
|
|
failure_multiplier: 0.0,
|
|
..QuasarConfig::default()
|
|
};
|
|
let peers: Vec<NodeID> = (1..=5).map(peer).collect();
|
|
let mut sampler = PhotonSampler::new(peers.clone(), &dark);
|
|
|
|
for p in &peers {
|
|
sampler.update_luminance(p, false);
|
|
assert_eq!(sampler.luminance().brightness(p), 0.0);
|
|
}
|
|
|
|
let drawn = sampler.sample(3);
|
|
assert_eq!(drawn.len(), 3, "a fully dimmed set sampled nobody");
|
|
let mut distinct = drawn.clone();
|
|
distinct.sort();
|
|
distinct.dedup();
|
|
assert_eq!(distinct.len(), 3, "the fallback repeated a peer");
|
|
for d in &drawn {
|
|
assert!(peers.contains(d));
|
|
}
|
|
}
|