Skip to content

Commit

Permalink
validator-api: create node status cache with selection probabilies (#…
Browse files Browse the repository at this point in the history
…1547)

* validator-api: create node status cache with selection probabilies

Create a node status cache to complement the contract cache. Initially
we store the simulated active set selection probabilities.

* validator-api: add validator cache watch channel

* changelog: add note

* validator-api: clippy fixes

* validator-api: fix clippy

* validator-api: additional fields to inclusion probabilities response

* selection chance: revert back to 3 buckets

* selection chance: revert buckets again

* rustfmt

* validator-api: remove the old get_mixnode_inclusion_probability

* node-status-cache: return error when refreshing

* inclusion-simulator: cap on wall clock time

* node status cache: tidy
  • Loading branch information
octol authored Aug 24, 2022
1 parent 3163c5f commit 8c8b7d7
Show file tree
Hide file tree
Showing 13 changed files with 532 additions and 77 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Post 1.0.0 release, the changelog format is based on [Keep a Changelog](https://
- validator-api: add Swagger to document the REST API ([#1249]).
- validator-api: Added new endpoints for coconut spending flow and communications with coconut & multisig contracts ([#1261])
- validator-api: add `uptime`, `estimated_operator_apy`, `estimated_delegators_apy` to `/mixnodes/detailed` endpoint ([#1393])
- validator-api: add node info cache storing simulated active set inclusion probabilities
- network-statistics: a new mixnet service that aggregates and exposes anonymized data about mixnet services ([#1328])
- mixnode: Added basic mixnode hardware reporting to the HTTP API ([#1308]).
- validator-api: endpoint, in coconut mode, for returning the validator-api cosmos address ([#1404]).
Expand Down
7 changes: 5 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions common/inclusion-probability/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
log = "0.4.17"
rand = "0.8.5"
thiserror = "1.0.32"
4 changes: 3 additions & 1 deletion common/inclusion-probability/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ pub enum Error {
EmptyListCumulStake,
#[error("Sample point was unexpectedly out of bounds")]
SamplePointOutOfBounds,
#[error("Norm computation failed on different size arrarys")]
#[error("Norm computation failed on different size arrays")]
NormDifferenceSizeArrays,
#[error("Computed probabilities are fewer than input number of nodes")]
ResultsShorterThanInput,
}
155 changes: 140 additions & 15 deletions common/inclusion-probability/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,45 @@
//! Active set inclusion probability simulator
use std::time::{Duration, Instant};

use error::Error;

mod error;

const TOLERANCE_L2_NORM: f64 = 1e-4;
const TOLERANCE_MAX_NORM: f64 = 1e-3;
const TOLERANCE_MAX_NORM: f64 = 1e-4;

pub struct SelectionProbability {
pub active_set_probability: Vec<f64>,
pub reserve_set_probability: Vec<f64>,
pub samples: u32,
pub samples: u64,
pub time: Duration,
pub delta_l2: f64,
pub delta_max: f64,
}

pub fn simulate_selection_probability_mixnodes(
list_stake_for_mixnodes: &[u64],
list_stake_for_mixnodes: &[u128],
active_set_size: usize,
reserve_set_size: usize,
max_samples: u32,
max_samples: u64,
max_time: Duration,
) -> Result<SelectionProbability, Error> {
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();

Expand All @@ -37,6 +56,9 @@ pub fn simulate_selection_probability_mixnodes(
let mut delta_max;
let mut rng = rand::thread_rng();

// 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();
Expand All @@ -46,7 +68,9 @@ pub fn simulate_selection_probability_mixnodes(
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 {
while sample_active_mixnodes.len() < active_set_size
&& sample_active_mixnodes.len() < list_cumul_temp.len()
{
let candidate = sample_candidate(&list_cumul_temp, &mut rng)?;

if !sample_active_mixnodes.contains(&candidate) {
Expand All @@ -56,7 +80,9 @@ pub fn simulate_selection_probability_mixnodes(
}

// Select the reserve nodes for the epoch (hour)
while sample_reserve_mixnodes.len() < reserve_set_size {
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, &mut rng)?;

if !sample_reserve_mixnodes.contains(&candidate)
Expand All @@ -78,35 +104,49 @@ pub fn simulate_selection_probability_mixnodes(
// 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)?
/ f64::from(samples);
delta_max = max_diff(&active_set_probability, &active_set_probability_previous)?
/ f64::from(samples);
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 /= f64::from(samples));
.for_each(|x| *x /= samples as f64);
reserve_set_probability
.iter_mut()
.for_each(|x| *x /= f64::from(samples));
.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 u64>) -> Vec<u64> {
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 {
Expand All @@ -116,7 +156,7 @@ fn cumul_sum<'a>(list: impl IntoIterator<Item = &'a u64>) -> Vec<u64> {
list_cumul
}

fn sample_candidate(list_cumul: &[u64], rng: &mut rand::rngs::ThreadRng) -> Result<usize, Error> {
fn sample_candidate(list_cumul: &[u128], rng: &mut rand::rngs::ThreadRng) -> Result<usize, Error> {
use rand::distributions::{Distribution, Uniform};
let uniform = Uniform::from(0..*list_cumul.last().ok_or(Error::EmptyListCumulStake)?);
let r = uniform.sample(rng);
Expand All @@ -132,7 +172,7 @@ fn sample_candidate(list_cumul: &[u64], rng: &mut rand::rngs::ThreadRng) -> Resu
}

// Update list of cumulative stake to reflect eliminating the picked node
fn remove_mixnode_from_cumul_stake(candidate: usize, list_cumul_stake: &mut [u64]) {
fn remove_mixnode_from_cumul_stake(candidate: usize, list_cumul_stake: &mut [u128]) {
let prob_candidate = if candidate == 0 {
list_cumul_stake[0]
} else {
Expand Down Expand Up @@ -212,18 +252,21 @@ mod tests {
];

let max_samples = 100_000;
let max_time = Duration::from_secs(10);

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,
)
.unwrap();

Expand Down Expand Up @@ -275,4 +318,86 @@ mod tests {
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 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,
)
.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 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,
)
.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!(samples < 20_500);
assert!(delta_l2 < TOLERANCE_L2_NORM);
assert!(delta_max < TOLERANCE_MAX_NORM);
}
}
4 changes: 4 additions & 0 deletions nym-wallet/src/pages/settings/system-variables.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@ const DataField = ({ title, info, Indicator }: { title: string; info: string; In
);

const colorMap: { [key in SelectionChance]: string } = {
VeryLow: 'error.main',
Low: 'error.main',
Moderate: 'warning.main',
High: 'success.main',
VeryHigh: 'success.main',
};

const textMap: { [key in SelectionChance]: string } = {
VeryLow: 'VeryLow',
Low: 'Low',
Moderate: 'Moderate',
High: 'High',
VeryHigh: 'Very high',
};

Expand Down
15 changes: 8 additions & 7 deletions validator-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ rust-version = "1.56"

[dependencies]
async-trait = "0.1.52"
cfg-if = "1.0"
clap = "2.33.0"
console-subscriber = { version = "0.1.1", optional = true} # validator-api needs to be built with RUSTFLAGS="--cfg tokio_unstable"
dirs = "4.0"
dotenv = "0.15.0"
futures = "0.3"
Expand All @@ -31,6 +33,7 @@ rocket = { version = "0.5.0-rc.2", features = ["json"] }
rocket_cors = { git="https://github.com/lawliet89/rocket_cors", rev="dfd3662c49e2f6fc37df35091cb94d82f7fb5915" }
serde = "1.0"
serde_json = "1.0"
tap = "1.0.1"
thiserror = "1"
time = { version = "0.3", features = ["serde-human-readable", "parsing"]}
tokio = { version = "1.19.1", features = ["rt-multi-thread", "macros", "signal", "time"] }
Expand All @@ -51,25 +54,23 @@ schemars = { version = "0.8", features = ["preserve_order"] }

## internal
coconut-bandwidth-contract-common = { path = "../common/cosmwasm-smart-contracts/coconut-bandwidth-contract" }
coconut-interface = { path = "../common/coconut-interface", optional = true }
config = { path = "../common/config" }
cosmwasm-std = "1.0.0"
credential-storage = { path = "../common/credential-storage" }
credentials = { path = "../common/credentials", optional = true }
crypto = { path="../common/crypto" }
gateway-client = { path="../common/client-libs/gateway-client" }
inclusion-probability = { path = "../common/inclusion-probability" }
mixnet-contract-common = { path= "../common/cosmwasm-smart-contracts/mixnet-contract" }
multisig-contract-common = { path = "../common/cosmwasm-smart-contracts/multisig-contract" }
nymsphinx = { path="../common/nymsphinx" }
nymcoconut = { path = "../common/nymcoconut", optional = true }
nymsphinx = { path="../common/nymsphinx" }
task = { path = "../common/task" }
topology = { path="../common/topology" }
validator-api-requests = { path = "validator-api-requests" }
validator-client = { path="../common/client-libs/validator-client", features = ["nymd-client"] }
version-checker = { path="../common/version-checker" }
coconut-interface = { path = "../common/coconut-interface", optional = true }
credentials = { path = "../common/credentials", optional = true }
credential-storage = { path = "../common/credential-storage" }
# validator-api needs to be built with RUSTFLAGS="--cfg tokio_unstable"
console-subscriber = { version = "0.1.1", optional = true}
cfg-if = "1.0"

[features]
coconut = ["coconut-interface", "credentials", "gateway-client/coconut", "credentials/coconut", "validator-api-requests/coconut", "nymcoconut"]
Expand Down
Loading

0 comments on commit 8c8b7d7

Please sign in to comment.