ariadne_simulator/
analyze.rs

1use std::{
2	collections::HashMap,
3	fmt::Debug,
4	io::{self, BufWriter},
5	time::{SystemTime, UNIX_EPOCH},
6};
7
8use crate::*;
9use itertools::*;
10use rand_chacha::ChaCha20Rng;
11use std::io::Write;
12
13/// Runs Ariadne selection and calculates various statistics for the selected committee.
14///
15/// This command writes output data as CSV either to standard output or a file with name
16/// `<target-dir>/ariadne-simulation-<timestamp>.csv`.
17///
18/// The following statistics are calculated for each committee:
19/// - `total_registered_stake`: total stake of the registered candidate pool
20/// - `total_committee_stake`: total stake of all selected committee members
21/// - `distinct_members`: number of unique members in the committee
22/// - `max_single_member_seats`: highest number of committee seats occupied by the same member
23/// - `safe_offline_members`: highest number of members that can be offline without affecting the consensus.
24///                           This number is calculated by greedily taking members with highest stake until
25///                           more than 33% of seats are offline.
26/// - `top_safe_offline_stake`: total stake of top stake candidates that can be offline without affecting the consensus
27/// - `bottom_safe_offline_stake`: total stake of lowest stake candidates that can be offline without affecting the consensus
28///
29/// Additionally, all input parameters are saved with the data.
30#[derive(clap::Parser, Debug)]
31pub struct Command {
32	/// Number of permissioned seats
33	#[arg(long, short = 'P')]
34	permissioned_seats: u16,
35	/// Number of registered seats
36	#[arg(long, short = 'R')]
37	registered_seats: u16,
38	/// File containing permissioned candidates, defaults to no permissioned candidates
39	#[arg(long, short = 'p')]
40	permissioned_file: Option<String>,
41	/// File containing registered candidates, defaults to no registered candidates
42	#[arg(long, short = 'r')]
43	registered_file: Option<String>,
44	/// Number of committees to select. Each committee will have a separate row in the output CSV file
45	#[arg(long, default_value = "l")]
46	repetitions: u32,
47	/// Number of registered candidates to be sampled from the `registered_file`. Defaults to the size of `registered_file`
48	#[arg(long)]
49	registered_pool_size: Option<u32>,
50	/// Ariadne algorithm version
51	#[arg(long, default_value = "v2")]
52	ariadne_version: AriadneVersion,
53	/// Determines whether to output to standard output instead of a file
54	#[arg(long, default_value = "false")]
55	output_to_terminal: bool,
56	/// Directory in which to save the output CSV file
57	#[arg(long)]
58	target_dir: Option<String>,
59}
60
61impl Command {
62	/// Executes the command using givern RNG
63	pub fn execute(self, mut rng: ChaCha20Rng) {
64		let potential_registered_candidates: Vec<(String, u128)> =
65			self.registered_file.clone().map(load_registered).unwrap_or_default();
66
67		let permissioned_candidates: Vec<String> =
68			self.permissioned_file.clone().map(load_permissioned).unwrap_or_default();
69
70		log::info!(
71			"Number of potential registered candidates: {}",
72			potential_registered_candidates.len()
73		);
74		log::info!("Number of permissioned candidates: {}", permissioned_candidates.len());
75
76		let output: &mut (dyn Write) = if self.output_to_terminal {
77			&mut io::stdout()
78		} else {
79			let file_name = format!(
80				"{}/ariadne-simulation-{}.csv",
81				self.target_dir.clone().unwrap_or(".".into()),
82				SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis()
83			);
84			&mut BufWriter::new(std::fs::File::create(file_name).unwrap())
85		};
86
87		writeln!(
88			output,
89			"{}",
90			[
91				"ariadne_version",
92				"R",
93				"P",
94				"registered_candidates",
95				"total_registered_stake",
96				"registered_file",
97				"permissioned_file",
98				"total_committee_stake",
99				"distinct_members",
100				"max_single_member_seats",
101				"safe_offline_members",
102				"top_safe_offline_stake",
103				"bottom_safe_offline_stake"
104			]
105			.join(",")
106		)
107		.unwrap();
108		for i in 0..self.repetitions {
109			if i % 100 == 0 && i > 0 {
110				log::info!("Generation progress: {i}/{}", self.repetitions);
111			}
112
113			// sample a registered candidate pool from all existing SPOs
114			let (registered_pool_size, registered_candidates) =
115				self.sample_registered(potential_registered_candidates.clone(), &mut rng);
116
117			let committee = self
118				.ariadne_version
119				.select_authorities(
120					self.registered_seats,
121					self.permissioned_seats,
122					registered_candidates.clone(),
123					permissioned_candidates.clone(),
124					&mut rng,
125				)
126				.expect("Selection failed");
127
128			let SelectionStats {
129				bottom_safe_offline_stake,
130				top_safe_offline_stake,
131				distinct_members,
132				max_single_member_seats,
133				safe_offline_members,
134				total_committee_stake,
135				total_registered_stake,
136			} = self.calculate_stats(&committee, &registered_candidates);
137
138			let registered_file =
139				self.registered_file.clone().map_or("null".to_string(), |f| format!("{f:?}"));
140			let permissioned_file =
141				self.permissioned_file.clone().map_or("null".to_string(), |f| format!("{f:?}"));
142
143			writeln!(
144				output,
145				"{},{},{},{},{},{},{},{},{},{},{},{},{}",
146				self.ariadne_version,
147				self.registered_seats,
148				self.permissioned_seats,
149				registered_pool_size,
150				total_registered_stake,
151				registered_file,
152				permissioned_file,
153				total_committee_stake,
154				distinct_members,
155				max_single_member_seats,
156				safe_offline_members,
157				top_safe_offline_stake,
158				bottom_safe_offline_stake
159			)
160			.expect("Failed to write CSV data row");
161		}
162	}
163
164	fn sample_registered(
165		&self,
166		potential_registered_candidates: Vec<(String, u128)>,
167		rng: &mut ChaCha20Rng,
168	) -> (usize, Vec<(String, u128)>) {
169		let (registered_pool_size, mut registered_candidates) = match self.registered_pool_size {
170			None => {
171				(potential_registered_candidates.len(), potential_registered_candidates.clone())
172			},
173			Some(registered_pool_size) => {
174				let registered_pool_size =
175					(registered_pool_size as usize).min(potential_registered_candidates.len());
176				let candidates = potential_registered_candidates
177					.clone()
178					.into_iter()
179					.choose_multiple(rng, registered_pool_size);
180				(registered_pool_size, candidates)
181			},
182		};
183		registered_candidates.shuffle(rng);
184		(registered_pool_size, registered_candidates)
185	}
186
187	fn calculate_stats(
188		&self,
189		committee: &[String],
190		registered_candidates: &[(String, u128)],
191	) -> SelectionStats {
192		let stake_lookup: HashMap<String, u128> = registered_candidates.iter().cloned().collect();
193		let mut member_seat_counts: Vec<(u16, String, u128)> = (committee.iter().cloned())
194			.into_group_map_by(|v| v.clone())
195			.into_iter()
196			.map(|(id, vs)| {
197				(vs.len() as u16, id.clone(), stake_lookup.get(&id).cloned().unwrap_or_default())
198			})
199			.collect();
200		member_seat_counts.sort();
201		member_seat_counts.reverse();
202		let total_seats = self.permissioned_seats + self.registered_seats;
203
204		// find the number of top seat members that can safely go offline
205		let mut safe_offline_members = 0;
206		let safety_threshold = (total_seats - 1) / 3;
207		let mut seats = 0;
208		let mut total_committee_stake = 0;
209		let mut top_safe_offline_stake = 0;
210		for (power, _, stake) in &member_seat_counts {
211			seats += power;
212			total_committee_stake += stake;
213			if seats <= safety_threshold {
214				top_safe_offline_stake += stake;
215				safe_offline_members += 1
216			}
217		}
218
219		let mut seats = 0;
220		let mut bottom_safe_offline_stake = 0;
221		for (power, _id, stake) in member_seat_counts.iter().rev() {
222			seats += power;
223			if seats <= safety_threshold {
224				bottom_safe_offline_stake += stake;
225			}
226		}
227
228		let total_registered_stake: u128 = registered_candidates.iter().map(|c| c.1).sum();
229		SelectionStats {
230			bottom_safe_offline_stake,
231			top_safe_offline_stake,
232			distinct_members: member_seat_counts.len(),
233			max_single_member_seats: member_seat_counts[0].0,
234			safe_offline_members: safe_offline_members as usize,
235			total_committee_stake,
236			total_registered_stake,
237		}
238	}
239}
240
241struct SelectionStats {
242	bottom_safe_offline_stake: u128,
243	top_safe_offline_stake: u128,
244	distinct_members: usize,
245	max_single_member_seats: u16,
246	safe_offline_members: usize,
247	total_committee_stake: u128,
248	total_registered_stake: u128,
249}