referrerpolicy=no-referrer-when-downgrade

sp_npos_elections/
pjr.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Implements functions and interfaces to check solutions for being t-PJR.
19//!
20//! PJR stands for proportional justified representation. PJR is an absolute measure to make
21//! sure an NPoS solution adheres to a minimum standard.
22//!
23//! See [`pjr_check`] which is the main entry point of the module.
24
25use crate::{
26	Candidate, CandidatePtr, Edge, ExtendedBalance, IdentifierT, Support, SupportMap, Supports,
27	VoteWeight, Voter,
28};
29use alloc::{collections::btree_map::BTreeMap, rc::Rc, vec::Vec};
30use sp_arithmetic::{traits::Zero, Perbill};
31/// The type used as the threshold.
32///
33/// Just some reading sugar; Must always be same as [`ExtendedBalance`];
34type Threshold = ExtendedBalance;
35
36/// Compute the threshold corresponding to the standard PJR property
37///
38/// `t-PJR` checks can check PJR according to an arbitrary threshold. The threshold can be any
39/// value, but the property gets stronger as the threshold gets smaller. The strongest possible
40/// `t-PJR` property corresponds to `t == 0`.
41///
42/// However, standard PJR is less stringent than that. This function returns the threshold whose
43/// strength corresponds to the standard PJR property.
44///
45/// - `committee_size` is the number of winners of the election.
46/// - `weights` is an iterator of voter stakes. If the sum of stakes is already known,
47///   `std::iter::once(sum_of_stakes)` is appropriate here.
48pub fn standard_threshold(
49	committee_size: usize,
50	weights: impl IntoIterator<Item = ExtendedBalance>,
51) -> Threshold {
52	weights
53		.into_iter()
54		.fold(Threshold::zero(), |acc, elem| acc.saturating_add(elem)) /
55		committee_size.max(1) as Threshold
56}
57
58/// Check a solution to be PJR.
59///
60/// The PJR property is true if `t-PJR` is true when `t == sum(stake) / committee_size`.
61pub fn pjr_check<AccountId: IdentifierT>(
62	supports: &Supports<AccountId>,
63	all_candidates: Vec<AccountId>,
64	all_voters: Vec<(AccountId, VoteWeight, Vec<AccountId>)>,
65) -> Result<(), AccountId> {
66	let t = standard_threshold(
67		supports.len(),
68		all_voters.iter().map(|voter| voter.1 as ExtendedBalance),
69	);
70	t_pjr_check(supports, all_candidates, all_voters, t)
71}
72
73/// Check a solution to be t-PJR.
74///
75/// ### Semantics
76///
77/// The t-PJR property is defined in the paper ["Validator Election in Nominated
78/// Proof-of-Stake"][NPoS], section 5, definition 1.
79///
80/// In plain language, the t-PJR condition is: if there is a group of `N` voters
81/// who have `r` common candidates and can afford to support each of them with backing stake `t`
82/// (i.e `sum(stake(v) for v in voters) == r * t`), then this committee needs to be represented by
83/// at least `r` elected candidates.
84///
85/// Section 5 of the NPoS paper shows that this property can be tested by: for a feasible solution,
86/// if `Max {score(c)} < t` where c is every unelected candidate, then this solution is t-PJR. There
87/// may exist edge cases which satisfy the formal definition of t-PJR but do not pass this test, but
88/// those should be rare enough that we can discount them.
89///
90/// ### Interface
91///
92/// In addition to data that can be computed from the [`Supports`] struct, a PJR check also
93/// needs to inspect un-elected candidates and edges, thus `all_candidates` and `all_voters`.
94///
95/// [NPoS]: https://arxiv.org/pdf/2004.12990v1.pdf
96// ### Implementation Notes
97//
98// The paper uses mathematical notation, which priorities single-symbol names. For programmer ease,
99// we map these to more descriptive names as follows:
100//
101// C      => all_candidates
102// N      => all_voters
103// (A, w) => (candidates, voters)
104//
105// Note that while the names don't explicitly say so, `candidates` are the winning candidates, and
106// `voters` is the set of weighted edges from nominators to winning validators.
107pub fn t_pjr_check<AccountId: IdentifierT>(
108	supports: &Supports<AccountId>,
109	all_candidates: Vec<AccountId>,
110	all_voters: Vec<(AccountId, VoteWeight, Vec<AccountId>)>,
111	t: Threshold,
112) -> Result<(), AccountId> {
113	// First order of business: derive `(candidates, voters)` from `supports`.
114	let (candidates, voters) = prepare_pjr_input(supports, all_candidates, all_voters);
115	// compute with threshold t.
116	pjr_check_core(candidates.as_ref(), voters.as_ref(), t)
117}
118
119/// The internal implementation of the PJR check after having the data converted.
120///
121/// [`pjr_check`] or [`t_pjr_check`] are typically easier to work with.
122///
123/// This function returns an `AccountId` in the `Err` case. This is the counter_example: the ID of
124/// the unelected candidate with the highest prescore, such that `pre_score(counter_example) >= t`.
125pub fn pjr_check_core<AccountId: IdentifierT>(
126	candidates: &[CandidatePtr<AccountId>],
127	voters: &[Voter<AccountId>],
128	t: Threshold,
129) -> Result<(), AccountId> {
130	let unelected = candidates.iter().filter(|c| !c.borrow().elected);
131	let maybe_max_pre_score = unelected
132		.map(|c| (pre_score(Rc::clone(c), voters, t), c.borrow().who.clone()))
133		.max();
134	// if unelected is empty then the solution is indeed PJR.
135	match maybe_max_pre_score {
136		Some((max_pre_score, counter_example)) if max_pre_score >= t => Err(counter_example),
137		_ => Ok(()),
138	}
139}
140
141/// Validate a challenge to an election result.
142///
143/// A challenge to an election result is valid if there exists some counter_example for which
144/// `pre_score(counter_example) >= threshold`. Validating an existing counter_example is
145/// computationally cheaper than re-running the PJR check.
146///
147/// This function uses the standard threshold.
148///
149/// Returns `true` if the challenge is valid: the proposed solution does not satisfy PJR.
150/// Returns `false` if the challenge is invalid: the proposed solution does in fact satisfy PJR.
151pub fn validate_pjr_challenge<AccountId: IdentifierT>(
152	counter_example: AccountId,
153	supports: &Supports<AccountId>,
154	all_candidates: Vec<AccountId>,
155	all_voters: Vec<(AccountId, VoteWeight, Vec<AccountId>)>,
156) -> bool {
157	let threshold = standard_threshold(
158		supports.len(),
159		all_voters.iter().map(|voter| voter.1 as ExtendedBalance),
160	);
161	validate_t_pjr_challenge(counter_example, supports, all_candidates, all_voters, threshold)
162}
163
164/// Validate a challenge to an election result.
165///
166/// A challenge to an election result is valid if there exists some counter_example for which
167/// `pre_score(counter_example) >= threshold`. Validating an existing counter_example is
168/// computationally cheaper than re-running the PJR check.
169///
170/// This function uses a supplied threshold.
171///
172/// Returns `true` if the challenge is valid: the proposed solution does not satisfy PJR.
173/// Returns `false` if the challenge is invalid: the proposed solution does in fact satisfy PJR.
174pub fn validate_t_pjr_challenge<AccountId: IdentifierT>(
175	counter_example: AccountId,
176	supports: &Supports<AccountId>,
177	all_candidates: Vec<AccountId>,
178	all_voters: Vec<(AccountId, VoteWeight, Vec<AccountId>)>,
179	threshold: Threshold,
180) -> bool {
181	let (candidates, voters) = prepare_pjr_input(supports, all_candidates, all_voters);
182	validate_pjr_challenge_core(counter_example, &candidates, &voters, threshold)
183}
184
185/// Validate a challenge to an election result.
186///
187/// A challenge to an election result is valid if there exists some counter_example for which
188/// `pre_score(counter_example) >= threshold`. Validating an existing counter_example is
189/// computationally cheaper than re-running the PJR check.
190///
191/// Returns `true` if the challenge is valid: the proposed solution does not satisfy PJR.
192/// Returns `false` if the challenge is invalid: the proposed solution does in fact satisfy PJR.
193fn validate_pjr_challenge_core<AccountId: IdentifierT>(
194	counter_example: AccountId,
195	candidates: &[CandidatePtr<AccountId>],
196	voters: &[Voter<AccountId>],
197	threshold: Threshold,
198) -> bool {
199	// Performing a linear search of the candidate list is not great, for obvious reasons. However,
200	// the alternatives are worse:
201	//
202	// - we could pre-sort the candidates list in `prepare_pjr_input` (n log n) which would let us
203	//   binary search for the appropriate one here (log n). Overall runtime is `n log n` which is
204	//   worse than the current runtime of `n`.
205	//
206	// - we could probably pre-sort the candidates list in `n` in `prepare_pjr_input` using some
207	//   unsafe code leveraging the existing `candidates_index`: allocate an uninitialized vector of
208	//   appropriate length, then copy in all the elements. We'd really prefer to avoid unsafe code
209	//   in the runtime, though.
210	let candidate =
211		match candidates.iter().find(|candidate| candidate.borrow().who == counter_example) {
212			None => return false,
213			Some(candidate) => candidate.clone(),
214		};
215	pre_score(candidate, voters, threshold) >= threshold
216}
217
218/// Convert the data types that the user runtime has into ones that can be used by this module.
219///
220/// It is expected that this function's interface might change over time, or multiple variants of it
221/// can be provided for different use cases.
222///
223/// The ultimate goal, in any case, is to convert the election data into [`Candidate`] and [`Voter`]
224/// types defined by this crate, whilst setting correct value for some of their fields, namely:
225/// 1. Candidate [`backing_stake`](Candidate::backing_stake) and [`elected`](Candidate::elected) if
226/// they are a winner. 2. Voter edge [`weight`](Edge::weight) if they are backing a winner.
227/// 3. Voter [`budget`](Voter::budget).
228///
229/// None of the `load` or `score` values are used and can be ignored. This is similar to
230/// [`setup_inputs`] function of this crate.
231///
232/// ### Performance (Weight) Notes
233///
234/// Note that the current function is rather unfortunately inefficient. The most significant
235/// slowdown is the fact that a typical solution that need to be checked for PJR only contains a
236/// subset of the entire NPoS edge graph, encoded as `supports`. This only encodes the
237/// edges that actually contribute to a winner's backing stake and ignores the rest to save space.
238/// To check PJR, we need the entire voter set, including those edges that point to non-winners.
239/// This could cause the caller runtime to have to read the entire list of voters, which is assumed
240/// to be expensive.
241///
242/// A sensible user of this module should make sure that the PJR check is executed and checked as
243/// little as possible, and take sufficient economical measures to ensure that this function cannot
244/// be abused.
245fn prepare_pjr_input<AccountId: IdentifierT>(
246	supports: &Supports<AccountId>,
247	all_candidates: Vec<AccountId>,
248	all_voters: Vec<(AccountId, VoteWeight, Vec<AccountId>)>,
249) -> (Vec<CandidatePtr<AccountId>>, Vec<Voter<AccountId>>) {
250	let mut candidates_index: BTreeMap<AccountId, usize> = BTreeMap::new();
251
252	// dump the staked assignments in a voter-major map for faster access down the road.
253	let mut assignment_map: BTreeMap<AccountId, Vec<(AccountId, ExtendedBalance)>> =
254		BTreeMap::new();
255	for (winner_id, Support { voters, .. }) in supports.iter() {
256		for (voter_id, support) in voters.iter() {
257			assignment_map
258				.entry(voter_id.clone())
259				.or_default()
260				.push((winner_id.clone(), *support));
261		}
262	}
263
264	// Convert Supports into a SupportMap
265	//
266	// As a flat list, we're limited to linear search. That gives the production of `candidates`,
267	// below, a complexity of `O(s*c)`, where `s == supports.len()` and `c == all_candidates.len()`.
268	// For large lists, that's pretty bad.
269	//
270	// A `SupportMap`, as a `BTreeMap`, has access timing of `O(lg n)`. This means that constructing
271	// the map and then indexing from it gives us timing of `O((s + c) * lg(s))`. If in the future
272	// we get access to a deterministic `HashMap`, we can further improve that to `O(s+c)`.
273	//
274	// However, it does mean allocating sufficient space to store all the data again.
275	let supports: SupportMap<AccountId> = supports.iter().cloned().collect();
276
277	// collect all candidates and winners into a unified `Vec<CandidatePtr>`.
278	let candidates = all_candidates
279		.into_iter()
280		.enumerate()
281		.map(|(i, c)| {
282			candidates_index.insert(c.clone(), i);
283
284			// set the backing value and elected flag if the candidate is among the winners.
285			let who = c;
286			let maybe_support = supports.get(&who);
287			let elected = maybe_support.is_some();
288			let backed_stake = maybe_support.map(|support| support.total).unwrap_or_default();
289
290			Candidate {
291				who,
292				elected,
293				backed_stake,
294				score: Default::default(),
295				approval_stake: Default::default(),
296				round: Default::default(),
297			}
298			.to_ptr()
299		})
300		.collect::<Vec<_>>();
301
302	// collect all voters into a unified Vec<Voters>.
303	let voters = all_voters
304		.into_iter()
305		.map(|(v, w, ts)| {
306			let mut edges: Vec<Edge<AccountId>> = Vec::with_capacity(ts.len());
307			for t in ts {
308				if edges.iter().any(|e| e.who == t) {
309					// duplicate edge.
310					continue
311				}
312
313				if let Some(idx) = candidates_index.get(&t) {
314					// if this edge is among the assignments, set the weight as well.
315					let weight = assignment_map
316						.get(&v)
317						.and_then(|d| {
318							d.iter().find_map(|(x, y)| if x == &t { Some(y) } else { None })
319						})
320						.cloned()
321						.unwrap_or_default();
322					edges.push(Edge {
323						who: t,
324						candidate: Rc::clone(&candidates[*idx]),
325						weight,
326						load: Default::default(),
327					});
328				}
329			}
330
331			let who = v;
332			let budget: ExtendedBalance = w.into();
333			Voter { who, budget, edges, load: Default::default() }
334		})
335		.collect::<Vec<_>>();
336
337	(candidates, voters)
338}
339
340/// The pre-score of an unelected candidate.
341///
342/// This is the amount of stake that *all voter* can spare to devote to this candidate without
343/// allowing the backing stake of any other elected candidate to fall below `t`.
344///
345/// In essence, it is the sum(slack(n, t)) for all `n` who vote for `unelected`.
346fn pre_score<AccountId: IdentifierT>(
347	unelected: CandidatePtr<AccountId>,
348	voters: &[Voter<AccountId>],
349	t: Threshold,
350) -> ExtendedBalance {
351	debug_assert!(!unelected.borrow().elected);
352	voters
353		.iter()
354		.filter(|v| v.votes_for(&unelected.borrow().who))
355		.fold(Zero::zero(), |acc: ExtendedBalance, voter| acc.saturating_add(slack(voter, t)))
356}
357
358/// The slack of a voter at a given state.
359///
360/// The slack of each voter, with threshold `t` is the total amount of stake that this voter can
361/// spare to a new potential member, whilst not dropping the backing stake of any of its currently
362/// active members below `t`. In essence, for each of the current active candidates `c`, we assume
363/// that we reduce the edge weight of `voter` to `c` from `w` to `w * min(1 / (t / support(c)))`.
364///
365/// More accurately:
366///
367/// 1. If `c` exactly has `t` backing or less, then we don't generate any slack.
368/// 2. If `c` has more than `t`, then we reduce it to `t`.
369fn slack<AccountId: IdentifierT>(voter: &Voter<AccountId>, t: Threshold) -> ExtendedBalance {
370	let budget = voter.budget;
371	let leftover = voter.edges.iter().fold(Zero::zero(), |acc: ExtendedBalance, edge| {
372		let candidate = edge.candidate.borrow();
373		if candidate.elected {
374			let extra =
375				Perbill::one().min(Perbill::from_rational(t, candidate.backed_stake)) * edge.weight;
376			acc.saturating_add(extra)
377		} else {
378			// No slack generated here.
379			acc
380		}
381	});
382
383	// NOTE: candidate for saturating_log_sub(). Defensive-only.
384	budget.saturating_sub(leftover)
385}
386
387#[cfg(test)]
388mod tests {
389	use super::*;
390
391	fn setup_voter(who: u32, votes: Vec<(u32, u128, bool)>) -> Voter<u32> {
392		let mut voter = Voter::new(who);
393		let mut budget = 0u128;
394		let candidates = votes
395			.into_iter()
396			.map(|(t, w, e)| {
397				budget += w;
398				Candidate {
399					who: t,
400					elected: e,
401					backed_stake: w,
402					score: Default::default(),
403					approval_stake: Default::default(),
404					round: Default::default(),
405				}
406			})
407			.collect::<Vec<_>>();
408		let edges = candidates
409			.into_iter()
410			.map(|c| Edge {
411				who: c.who,
412				weight: c.backed_stake,
413				candidate: c.to_ptr(),
414				load: Default::default(),
415			})
416			.collect::<Vec<_>>();
417		voter.edges = edges;
418		voter.budget = budget;
419		voter
420	}
421
422	fn assert_core_failure<AccountId: IdentifierT>(
423		candidates: &[CandidatePtr<AccountId>],
424		voters: &[Voter<AccountId>],
425		t: Threshold,
426	) {
427		let counter_example = pjr_check_core(candidates, voters, t).unwrap_err();
428		assert!(validate_pjr_challenge_core(counter_example, candidates, voters, t));
429	}
430
431	#[test]
432	fn slack_works() {
433		let voter = setup_voter(10, vec![(1, 10, true), (2, 20, true)]);
434
435		assert_eq!(slack(&voter, 15), 5);
436		assert_eq!(slack(&voter, 17), 3);
437		assert_eq!(slack(&voter, 10), 10);
438		assert_eq!(slack(&voter, 5), 20);
439	}
440
441	#[test]
442	fn pre_score_works() {
443		// will give 5 slack
444		let v1 = setup_voter(10, vec![(1, 10, true), (2, 20, true), (3, 0, false)]);
445		// will give no slack
446		let v2 = setup_voter(20, vec![(1, 5, true), (2, 5, true)]);
447		// will give 10 slack.
448		let v3 = setup_voter(30, vec![(1, 20, true), (2, 20, true), (3, 0, false)]);
449
450		let unelected = Candidate {
451			who: 3u32,
452			elected: false,
453			score: Default::default(),
454			approval_stake: Default::default(),
455			backed_stake: Default::default(),
456			round: Default::default(),
457		}
458		.to_ptr();
459		let score = pre_score(unelected, &vec![v1, v2, v3], 15);
460
461		assert_eq!(score, 15);
462	}
463
464	#[test]
465	fn can_convert_data_from_external_api() {
466		let all_candidates = vec![10, 20, 30, 40];
467		let all_voters = vec![
468			(1, 10, vec![10, 20, 30, 40]),
469			(2, 20, vec![10, 20, 30, 40]),
470			(3, 30, vec![10, 30]),
471		];
472		// tuples in voters vector are (AccountId, Balance)
473		let supports: Supports<u32> = vec![
474			(20, Support { total: 15, voters: vec![(1, 5), (2, 10)] }),
475			(40, Support { total: 15, voters: vec![(1, 5), (2, 10)] }),
476		];
477
478		let (candidates, voters) = prepare_pjr_input(&supports, all_candidates, all_voters);
479
480		// elected flag and backing must be set correctly
481		assert_eq!(
482			candidates
483				.iter()
484				.map(|c| (c.borrow().who, c.borrow().elected, c.borrow().backed_stake))
485				.collect::<Vec<_>>(),
486			vec![(10, false, 0), (20, true, 15), (30, false, 0), (40, true, 15)],
487		);
488
489		// edge weight must be set correctly
490		assert_eq!(
491			voters
492				.iter()
493				.map(|v| (
494					v.who,
495					v.budget,
496					v.edges.iter().map(|e| (e.who, e.weight)).collect::<Vec<_>>(),
497				))
498				.collect::<Vec<_>>(),
499			vec![
500				(1, 10, vec![(10, 0), (20, 5), (30, 0), (40, 5)]),
501				(2, 20, vec![(10, 0), (20, 10), (30, 0), (40, 10)]),
502				(3, 30, vec![(10, 0), (30, 0)]),
503			],
504		);
505
506		// fyi. this is not PJR, obviously because the votes of 3 can bump the stake a lot but they
507		// are being ignored.
508		assert_core_failure(&candidates, &voters, 1);
509		assert_core_failure(&candidates, &voters, 10);
510		assert_core_failure(&candidates, &voters, 20);
511	}
512
513	// These next tests ensure that the threshold phase change property holds for us, but that's not
514	// their real purpose. They were written to help develop an intuition about what the threshold
515	// value actually means in layman's terms.
516	//
517	// The results tend to support the intuition that the threshold is the voting power at and below
518	// which a voter's preferences can simply be ignored.
519	#[test]
520	fn find_upper_bound_for_threshold_scenario_1() {
521		let all_candidates = vec![10, 20, 30, 40];
522		let all_voters = vec![
523			(1, 10, vec![10, 20, 30, 40]),
524			(2, 20, vec![10, 20, 30, 40]),
525			(3, 30, vec![10, 30]),
526		];
527		// tuples in voters vector are (AccountId, Balance)
528		let supports: Supports<u32> = vec![
529			(20, Support { total: 15, voters: vec![(1, 5), (2, 10)] }),
530			(40, Support { total: 15, voters: vec![(1, 5), (2, 10)] }),
531		];
532
533		let (candidates, voters) = prepare_pjr_input(&supports, all_candidates, all_voters);
534
535		find_threshold_phase_change_for_scenario(candidates, voters);
536	}
537
538	#[test]
539	fn find_upper_bound_for_threshold_scenario_2() {
540		let all_candidates = vec![10, 20, 30, 40];
541		let all_voters = vec![
542			(1, 10, vec![10, 20, 30, 40]),
543			(2, 20, vec![10, 20, 30, 40]),
544			(3, 25, vec![10, 30]),
545		];
546		// tuples in voters vector are (AccountId, Balance)
547		let supports: Supports<u32> = vec![
548			(20, Support { total: 15, voters: vec![(1, 5), (2, 10)] }),
549			(40, Support { total: 15, voters: vec![(1, 5), (2, 10)] }),
550		];
551
552		let (candidates, voters) = prepare_pjr_input(&supports, all_candidates, all_voters);
553
554		find_threshold_phase_change_for_scenario(candidates, voters);
555	}
556
557	#[test]
558	fn find_upper_bound_for_threshold_scenario_3() {
559		let all_candidates = vec![10, 20, 30, 40];
560		let all_voters = vec![
561			(1, 10, vec![10, 20, 30, 40]),
562			(2, 20, vec![10, 20, 30, 40]),
563			(3, 35, vec![10, 30]),
564		];
565		// tuples in voters vector are (AccountId, Balance)
566		let supports: Supports<u32> = vec![
567			(20, Support { total: 15, voters: vec![(1, 5), (2, 10)] }),
568			(40, Support { total: 15, voters: vec![(1, 5), (2, 10)] }),
569		];
570
571		let (candidates, voters) = prepare_pjr_input(&supports, all_candidates, all_voters);
572
573		find_threshold_phase_change_for_scenario(candidates, voters);
574	}
575
576	fn find_threshold_phase_change_for_scenario<AccountId: IdentifierT>(
577		candidates: Vec<CandidatePtr<AccountId>>,
578		voters: Vec<Voter<AccountId>>,
579	) -> Threshold {
580		let mut threshold = 1;
581		let mut prev_threshold = 0;
582
583		// find the binary range containing the threshold beyond which the PJR check succeeds
584		while pjr_check_core(&candidates, &voters, threshold).is_err() {
585			prev_threshold = threshold;
586			threshold = threshold
587				.checked_mul(2)
588				.expect("pjr check must fail before we run out of capacity in u128");
589		}
590
591		// now binary search within that range to find the phase threshold
592		let mut high_bound = threshold;
593		let mut low_bound = prev_threshold;
594
595		while high_bound - low_bound > 1 {
596			// maintain the invariant that low_bound fails and high_bound passes
597			let test = low_bound + ((high_bound - low_bound) / 2);
598			if pjr_check_core(&candidates, &voters, test).is_ok() {
599				high_bound = test;
600			} else {
601				low_bound = test;
602			}
603		}
604
605		println!("highest failing check:   {}", low_bound);
606		println!("lowest succeeding check: {}", high_bound);
607
608		// for a value to be a threshold, it must be the boundary between two conditions
609		let mut unexpected_failures = Vec::new();
610		let mut unexpected_successes = Vec::new();
611		for t in 0..=low_bound {
612			if pjr_check_core(&candidates, &voters, t).is_ok() {
613				unexpected_successes.push(t);
614			}
615		}
616		for t in high_bound..(high_bound * 2) {
617			if pjr_check_core(&candidates, &voters, t).is_err() {
618				unexpected_failures.push(t);
619			}
620		}
621		dbg!(&unexpected_successes, &unexpected_failures);
622		assert!(unexpected_failures.is_empty() && unexpected_successes.is_empty());
623
624		high_bound
625	}
626}