Files
nym/common/inclusion-probability/src/lib.rs
T
Jędrzej Stuczyński 827c13b69e moved nym-gateway-probe to monorepo and updated rust-edition to 2024 (#6094)
dont build netstack in CI

additional rust 2024 fixes

fixes

removed temp.rs

first round of cleanup

removed duplicated NS types

moved gateway probe to the monorepo
2025-10-08 16:17:43 +01:00

427 lines
13 KiB
Rust

//! Active set inclusion probability simulator
use std::time::{Duration, Instant};
use error::Error;
use rand::Rng;
mod error;
const TOLERANCE_L2_NORM: f64 = 1e-4;
const TOLERANCE_MAX_NORM: f64 = 1e-4;
pub struct SelectionProbability {
pub active_set_probability: Vec<f64>,
pub reserve_set_probability: Vec<f64>,
pub samples: u64,
pub time: Duration,
pub delta_l2: f64,
pub delta_max: f64,
}
pub fn simulate_selection_probability_mixnodes<R>(
list_stake_for_mixnodes: &[u128],
active_set_size: usize,
reserve_set_size: usize,
max_samples: u64,
max_time: Duration,
rng: &mut R,
) -> Result<SelectionProbability, Error>
where
R: Rng + ?Sized,
{
log::trace!("Simulating mixnode active set selection probability");
// In case the active set size is larger than the number of bonded mixnodes, they all have 100%
// chance we don't have to go through with the simulation
if list_stake_for_mixnodes.len() <= active_set_size {
return Ok(SelectionProbability {
active_set_probability: vec![1.0; list_stake_for_mixnodes.len()],
reserve_set_probability: vec![0.0; list_stake_for_mixnodes.len()],
samples: 0,
time: Duration::ZERO,
delta_l2: 0.0,
delta_max: 0.0,
});
}
// Total number of existing (registered) nodes
let num_mixnodes = list_stake_for_mixnodes.len();
// Cumulative stake ordered by node index
let list_cumul = cumul_sum(list_stake_for_mixnodes);
// The computed probabilities
let mut active_set_probability = vec![0.0; num_mixnodes];
let mut reserve_set_probability = vec![0.0; num_mixnodes];
// Number sufficiently large to have a good approximation of selection probability
let mut samples = 0;
let mut delta_l2;
let mut delta_max;
// Make sure we bound the time we allow it to run
let start_time = Instant::now();
loop {
samples += 1;
let mut sample_active_mixnodes = Vec::new();
let mut sample_reserve_mixnodes = Vec::new();
let mut list_cumul_temp = list_cumul.clone();
let active_set_probability_previous = active_set_probability.clone();
// Select the active nodes for the epoch (hour)
while sample_active_mixnodes.len() < active_set_size
&& sample_active_mixnodes.len() < list_cumul_temp.len()
{
let candidate = sample_candidate(&list_cumul_temp, rng)?;
if !sample_active_mixnodes.contains(&candidate) {
sample_active_mixnodes.push(candidate);
remove_mixnode_from_cumul_stake(candidate, &mut list_cumul_temp);
}
}
// Select the reserve nodes for the epoch (hour)
while sample_reserve_mixnodes.len() < reserve_set_size
&& sample_reserve_mixnodes.len() + sample_active_mixnodes.len() < list_cumul_temp.len()
{
let candidate = sample_candidate(&list_cumul_temp, rng)?;
if !sample_reserve_mixnodes.contains(&candidate)
&& !sample_active_mixnodes.contains(&candidate)
{
sample_reserve_mixnodes.push(candidate);
remove_mixnode_from_cumul_stake(candidate, &mut list_cumul_temp);
}
}
// Sum up nodes being in active or reserve set
for active_mixnodes in sample_active_mixnodes {
active_set_probability[active_mixnodes] += 1.0;
}
for reserve_mixnodes in sample_reserve_mixnodes {
reserve_set_probability[reserve_mixnodes] += 1.0;
}
// Convergence critera only on active set.
// We devide by samples to get the average, that is not really part of the delta
// computation.
delta_l2 =
l2_diff(&active_set_probability, &active_set_probability_previous)? / (samples as f64);
delta_max =
max_diff(&active_set_probability, &active_set_probability_previous)? / (samples as f64);
if samples > 10 && delta_l2 < TOLERANCE_L2_NORM && delta_max < TOLERANCE_MAX_NORM
|| samples >= max_samples
{
break;
}
// Stop if we run out of time
if start_time.elapsed() > max_time {
log::debug!("Simulation ran out of time, stopping");
break;
}
}
// Divide occurrences with the number of samples once we're done to get the probabilities.
active_set_probability
.iter_mut()
.for_each(|x| *x /= samples as f64);
reserve_set_probability
.iter_mut()
.for_each(|x| *x /= samples as f64);
// Some sanity checks of the output
if active_set_probability.len() != num_mixnodes || reserve_set_probability.len() != num_mixnodes
{
return Err(Error::ResultsShorterThanInput);
}
Ok(SelectionProbability {
active_set_probability,
reserve_set_probability,
samples,
time: start_time.elapsed(),
delta_l2,
delta_max,
})
}
// Compute the cumulative sum
fn cumul_sum<'a>(list: impl IntoIterator<Item = &'a u128>) -> Vec<u128> {
let mut list_cumul = Vec::new();
let mut cumul = 0;
for entry in list {
cumul += entry;
list_cumul.push(cumul);
}
list_cumul
}
fn sample_candidate<R>(list_cumul: &[u128], rng: &mut R) -> Result<usize, Error>
where
R: Rng + ?Sized,
{
use rand::distributions::{Distribution, Uniform};
let uniform = Uniform::from(0..*list_cumul.last().ok_or(Error::EmptyListCumulStake)?);
let r = uniform.sample(rng);
let candidate = list_cumul
.iter()
.enumerate()
.find(|(_, x)| *x >= &r)
.ok_or(Error::SamplePointOutOfBounds)?
.0;
Ok(candidate)
}
// Update list of cumulative stake to reflect eliminating the picked node
fn remove_mixnode_from_cumul_stake(candidate: usize, list_cumul_stake: &mut [u128]) {
let prob_candidate = if candidate == 0 {
list_cumul_stake[0]
} else {
list_cumul_stake[candidate] - list_cumul_stake[candidate - 1]
};
for cumul in list_cumul_stake.iter_mut().skip(candidate) {
*cumul -= prob_candidate;
}
}
// Compute the difference in l2-norm
fn l2_diff(v1: &[f64], v2: &[f64]) -> Result<f64, Error> {
if v1.len() != v2.len() {
return Err(Error::NormDifferenceSizeArrays);
}
Ok(v1
.iter()
.zip(v2)
.map(|(&i1, &i2)| (i1 - i2).powi(2))
.sum::<f64>()
.sqrt())
}
// Compute the difference in max-norm
fn max_diff(v1: &[f64], v2: &[f64]) -> Result<f64, Error> {
if v1.len() != v2.len() {
return Err(Error::NormDifferenceSizeArrays);
}
Ok(v1
.iter()
.zip(v2)
.map(|(x, y)| (x - y).abs())
.fold(f64::NEG_INFINITY, f64::max))
}
#[cfg(test)]
mod tests {
use rand::{SeedableRng, rngs::StdRng};
use super::*;
fn test_rng() -> StdRng {
StdRng::seed_from_u64(42)
}
#[test]
fn compute_cumul_sum() {
let v = cumul_sum(&vec![1, 2, 3]);
assert_eq!(v, &[1, 3, 6]);
}
#[test]
fn remove_mixnode_from_cumul() {
let mut cumul_stake = vec![1, 2, 3, 4, 5, 6];
remove_mixnode_from_cumul_stake(3, &mut cumul_stake);
assert_eq!(cumul_stake, &[1, 2, 3, 3, 4, 5]);
}
#[test]
fn max_norm() {
let v1 = vec![1.0, 2.0, 3.0];
let v2 = vec![2.0, 4.0, -6.0];
assert!((max_diff(&v1, &v2).unwrap() - 9.0).abs() < f64::EPSILON);
}
#[test]
fn ls_norm() {
let v1 = vec![1.0, 2.0, 3.0];
let v2 = vec![2.0, 3.0, -2.0];
assert!((l2_diff(&v1, &v2).unwrap() - 5.196_152_422_706_632).abs() < 1e2 * f64::EPSILON);
}
// Replicate the results from the Python simulation code in https://github.com/nymtech/team-core/issues/114
#[test]
fn replicate_python_simulation() {
let active_set_size = 4;
let standby_set_size = 1;
// this has to contain the total stake per node
let list_mix = vec![
100, 100, 3000, 500_000, 100, 10, 10, 10, 10, 10, 30000, 500, 200, 52345,
];
let max_samples = 100_000;
let max_time = Duration::from_secs(10);
let mut rng = test_rng();
let SelectionProbability {
active_set_probability,
reserve_set_probability,
samples,
time,
delta_l2,
delta_max,
} = simulate_selection_probability_mixnodes(
&list_mix,
active_set_size,
standby_set_size,
max_samples,
max_time,
&mut rng,
)
.unwrap();
// Check that any possible test failure wasn't because we ran it on 1970s hardware, and the
// sampling aborted prematurely due to hitting `max_time`.
assert!(time < max_time);
// These values comes from running the python simulator for a very long time
let expected_active_set_probability = vec![
0.025_070_8,
0.025_073_2,
0.744_117,
0.999_999,
0.025_000_2,
0.002_524_4,
0.002_527_8,
0.002_528_6,
0.002_569_6,
0.002_513_6,
0.994,
0.125_482_8,
0.050_279_8,
0.998_313_2,
];
// The same check is used in the convergence criterion, and hence should be reflected in
// `delta_max` too.
assert!(
max_diff(&active_set_probability, &expected_active_set_probability).unwrap() < 1e-2
);
let expected_reserve_set_probability = vec![
0.076_392_4,
0.076_499,
0.204_893_6,
1e-06,
0.076_278_8,
0.007_720_6,
0.007_673,
0.007_700_2,
0.007_669_4,
0.007_731_2,
0.005_789_4,
0.368_465_6,
0.151_537_2,
0.001_648_6,
];
assert!(
max_diff(&reserve_set_probability, &expected_reserve_set_probability).unwrap() < 1e-2
);
// We converge around 20_000, add another 500 for some slack due to random values
assert_eq!(samples, 20_001);
assert!(delta_l2 < TOLERANCE_L2_NORM);
assert!(delta_max < TOLERANCE_MAX_NORM);
}
#[test]
fn fewer_nodes_than_active_set_size() {
let active_set_size = 10;
let standby_set_size = 3;
let list_mix = vec![100, 100, 3000];
let max_samples = 100_000;
let max_time = Duration::from_secs(10);
let mut rng = test_rng();
let SelectionProbability {
active_set_probability,
reserve_set_probability,
samples,
time: _,
delta_l2,
delta_max,
} = simulate_selection_probability_mixnodes(
&list_mix,
active_set_size,
standby_set_size,
max_samples,
max_time,
&mut rng,
)
.unwrap();
// These values comes from running the python simulator for a very long time
let expected_active_set_probability = vec![1.0, 1.0, 1.0];
let expected_reserve_set_probability = vec![0.0, 0.0, 0.0];
assert!(
max_diff(&active_set_probability, &expected_active_set_probability).unwrap()
< 1e1 * f64::EPSILON
);
assert!(
max_diff(&reserve_set_probability, &expected_reserve_set_probability).unwrap()
< 1e1 * f64::EPSILON
);
// We converge around 20_000, add another 500 for some slack due to random values
assert_eq!(samples, 0);
assert!(delta_l2 < f64::EPSILON);
assert!(delta_max < f64::EPSILON);
}
#[test]
fn fewer_nodes_than_reward_set_size() {
let active_set_size = 4;
let standby_set_size = 3;
let list_mix = vec![100, 100, 3000, 342, 3_498_234];
let max_samples = 100_000_000;
let max_time = Duration::from_secs(10);
let mut rng = test_rng();
let SelectionProbability {
active_set_probability,
reserve_set_probability,
samples,
time: _,
delta_l2,
delta_max,
} = simulate_selection_probability_mixnodes(
&list_mix,
active_set_size,
standby_set_size,
max_samples,
max_time,
&mut rng,
)
.unwrap();
// These values comes from running the python simulator for a very long time
let expected_active_set_probability = vec![0.546, 0.538, 0.999, 0.915, 1.0];
let expected_reserve_set_probability = vec![0.453, 0.461, 0.0005, 0.084, 0.0];
assert!(
max_diff(&active_set_probability, &expected_active_set_probability).unwrap() < 1e-2,
);
assert!(
max_diff(&reserve_set_probability, &expected_reserve_set_probability).unwrap() < 1e-2,
);
// We converge around 20_000, add another 500 for some slack due to random values
assert_eq!(samples, 20_001);
assert!(delta_l2 < TOLERANCE_L2_NORM);
assert!(delta_max < TOLERANCE_MAX_NORM);
}
}