From 69b38e59d98769ed93a0da26570cc714fa66240e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Pestana?= Date: Thu, 14 Nov 2024 16:37:22 +0100 Subject: [PATCH] Adds max backers per winner bounds check in the phragmen implementation (#6482) --- .../election-provider-multi-phase/src/mock.rs | 16 +++- .../src/unsigned.rs | 95 ++++++++++++++++++- .../election-provider-support/src/lib.rs | 20 +++- .../election-provider-support/src/onchain.rs | 5 +- .../primitives/npos-elections/src/lib.rs | 11 ++- .../primitives/npos-elections/src/mock.rs | 2 + .../primitives/npos-elections/src/phragmen.rs | 28 +++++- .../primitives/npos-elections/src/pjr.rs | 6 ++ .../primitives/npos-elections/src/tests.rs | 80 +++++++++++++--- 9 files changed, 232 insertions(+), 31 deletions(-) diff --git a/substrate/frame/election-provider-multi-phase/src/mock.rs b/substrate/frame/election-provider-multi-phase/src/mock.rs index 4add202ebc04..100abc01c2d9 100644 --- a/substrate/frame/election-provider-multi-phase/src/mock.rs +++ b/substrate/frame/election-provider-multi-phase/src/mock.rs @@ -153,7 +153,8 @@ pub fn trim_helpers() -> TrimHelpers { let desired_targets = crate::DesiredTargets::::get().unwrap(); let ElectionResult::<_, SolutionAccuracyOf> { mut assignments, .. } = - seq_phragmen(desired_targets as usize, targets.clone(), voters.clone(), None).unwrap(); + seq_phragmen(desired_targets as usize, targets.clone(), voters.clone(), None, None) + .unwrap(); // sort by decreasing order of stake assignments.sort_by_key(|assignment| { @@ -180,7 +181,8 @@ pub fn raw_solution() -> RawSolution> { let desired_targets = crate::DesiredTargets::::get().unwrap(); let ElectionResult::<_, SolutionAccuracyOf> { winners: _, assignments } = - seq_phragmen(desired_targets as usize, targets.clone(), voters.clone(), None).unwrap(); + seq_phragmen(desired_targets as usize, targets.clone(), voters.clone(), None, None) + .unwrap(); // closures let cache = helpers::generate_voter_cache::(&voters); @@ -308,7 +310,8 @@ parameter_types! { pub struct OnChainSeqPhragmen; impl onchain::Config for OnChainSeqPhragmen { type System = Runtime; - type Solver = SequentialPhragmen, Balancing>; + type Solver = + SequentialPhragmen, MaxBackersPerWinner, Balancing>; type DataProvider = StakingMock; type WeightInfo = (); type MaxWinnersPerPage = MaxWinners; @@ -420,7 +423,8 @@ impl crate::Config for Runtime { type MaxWinners = MaxWinners; type MaxBackersPerWinner = MaxBackersPerWinner; type MinerConfig = Self; - type Solver = SequentialPhragmen, Balancing>; + type Solver = + SequentialPhragmen, MaxBackersPerWinner, Balancing>; type ElectionBounds = ElectionsBounds; } @@ -607,6 +611,10 @@ impl ExtBuilder { ::set(weight); self } + pub fn max_backers_per_winner(self, max: u32) -> Self { + ::set(max); + self + } pub fn build(self) -> sp_io::TestExternalities { sp_tracing::try_init_simple(); let mut storage = diff --git a/substrate/frame/election-provider-multi-phase/src/unsigned.rs b/substrate/frame/election-provider-multi-phase/src/unsigned.rs index 90b12343aaeb..aa2b8f265bfb 100644 --- a/substrate/frame/election-provider-multi-phase/src/unsigned.rs +++ b/substrate/frame/election-provider-multi-phase/src/unsigned.rs @@ -430,7 +430,7 @@ pub trait MinerConfig { /// The maximum number of winners that can be elected in the single page supported by this /// pallet. type MaxWinners: Get; - /// The maximum number of backers per winner in the last solution. + /// The maximum number of backers per winner in a solution. type MaxBackersPerWinner: Get; /// Something that can compute the weight of a solution. /// @@ -1865,6 +1865,99 @@ mod tests { }) } + #[test] + fn mine_solution_always_respects_max_backers_per_winner() { + use crate::mock::MaxBackersPerWinner; + use frame_election_provider_support::BoundedSupport; + + let targets = vec![10, 20, 30, 40]; + let voters = vec![ + (1, 10, bounded_vec![10, 20, 30]), + (2, 10, bounded_vec![10, 20, 30]), + (3, 10, bounded_vec![10, 20, 30]), + (4, 10, bounded_vec![10, 20, 30]), + (5, 10, bounded_vec![10, 20, 40]), + ]; + let snapshot = RoundSnapshot { voters: voters.clone(), targets: targets.clone() }; + let (round, desired_targets) = (1, 3); + + let expected_score_unbounded = + ElectionScore { minimal_stake: 12, sum_stake: 50, sum_stake_squared: 874 }; + let expected_score_bounded = + ElectionScore { minimal_stake: 2, sum_stake: 10, sum_stake_squared: 44 }; + + // solution without max_backers_per_winner set will be higher than the score when bounds + // are set, confirming the trimming when using the same snapshot state. + assert!(expected_score_unbounded > expected_score_bounded); + + // election with unbounded max backers per winnner. + ExtBuilder::default().max_backers_per_winner(u32::MAX).build_and_execute(|| { + assert_eq!(MaxBackersPerWinner::get(), u32::MAX); + + let solution = Miner::::mine_solution_with_snapshot::< + ::Solver, + >(voters.clone(), targets.clone(), desired_targets) + .unwrap() + .0; + + let ready_solution = Miner::::feasibility_check( + RawSolution { solution, score: expected_score_unbounded, round }, + Default::default(), + desired_targets, + snapshot.clone(), + round, + Default::default(), + ) + .unwrap(); + + assert_eq!( + ready_solution.supports.into_iter().collect::>(), + vec![ + ( + 10, + BoundedSupport { total: 21, voters: bounded_vec![(1, 10), (4, 8), (5, 3)] } + ), + (20, BoundedSupport { total: 17, voters: bounded_vec![(2, 10), (5, 7)] }), + (30, BoundedSupport { total: 12, voters: bounded_vec![(3, 10), (4, 2)] }), + ] + ); + }); + + // election with max 1 backer per winnner. + ExtBuilder::default().max_backers_per_winner(1).build_and_execute(|| { + assert_eq!(MaxBackersPerWinner::get(), 1); + + let solution = Miner::::mine_solution_with_snapshot::< + ::Solver, + >(voters, targets, desired_targets) + .unwrap() + .0; + + let ready_solution = Miner::::feasibility_check( + RawSolution { solution, score: expected_score_bounded, round }, + Default::default(), + desired_targets, + snapshot, + round, + Default::default(), + ) + .unwrap(); + + for (_, supports) in ready_solution.supports.iter() { + assert!((supports.voters.len() as u32) <= MaxBackersPerWinner::get()); + } + + assert_eq!( + ready_solution.supports.into_iter().collect::>(), + vec![ + (10, BoundedSupport { total: 6, voters: bounded_vec![(1, 6)] }), + (20, BoundedSupport { total: 2, voters: bounded_vec![(1, 2)] }), + (30, BoundedSupport { total: 2, voters: bounded_vec![(1, 2)] }), + ] + ); + }); + } + #[test] fn trim_assignments_length_does_not_modify_when_short_enough() { ExtBuilder::default().build_and_execute(|| { diff --git a/substrate/frame/election-provider-support/src/lib.rs b/substrate/frame/election-provider-support/src/lib.rs index 49bd533cc8c3..86bc085ca0a7 100644 --- a/substrate/frame/election-provider-support/src/lib.rs +++ b/substrate/frame/election-provider-support/src/lib.rs @@ -670,12 +670,16 @@ pub trait NposSolver { /// A wrapper for [`sp_npos_elections::seq_phragmen`] that implements [`NposSolver`]. See the /// documentation of [`sp_npos_elections::seq_phragmen`] for more info. -pub struct SequentialPhragmen( - core::marker::PhantomData<(AccountId, Accuracy, Balancing)>, +pub struct SequentialPhragmen( + core::marker::PhantomData<(AccountId, Accuracy, MaxBackersPerWinner, Balancing)>, ); -impl>> - NposSolver for SequentialPhragmen +impl< + AccountId: IdentifierT, + Accuracy: PerThing128, + MaxBackersPerWinner: Get>, + Balancing: Get>, + > NposSolver for SequentialPhragmen { type AccountId = AccountId; type Accuracy = Accuracy; @@ -685,7 +689,13 @@ impl, voters: Vec<(Self::AccountId, VoteWeight, impl IntoIterator)>, ) -> Result, Self::Error> { - sp_npos_elections::seq_phragmen(winners, targets, voters, Balancing::get()) + sp_npos_elections::seq_phragmen( + winners, + targets, + voters, + MaxBackersPerWinner::get(), + Balancing::get(), + ) } fn weight(voters: u32, targets: u32, vote_degree: u32) -> Weight { diff --git a/substrate/frame/election-provider-support/src/onchain.rs b/substrate/frame/election-provider-support/src/onchain.rs index 379dccee2ce6..5e4f9b54984c 100644 --- a/substrate/frame/election-provider-support/src/onchain.rs +++ b/substrate/frame/election-provider-support/src/onchain.rs @@ -145,8 +145,9 @@ impl OnChainExecution { DispatchClass::Mandatory, ); - // defensive: Since npos solver returns a result always bounded by `desired_targets`, this - // is never expected to happen as long as npos solver does what is expected for it to do. + // defensive: Since npos solver returns a result always bounded by `desired_targets`, and + // ensures the maximum backers per winner, this is never expected to happen as long as npos + // solver does what is expected for it to do. let supports: BoundedSupportsOf = to_supports(&staked).try_into().map_err(|_| Error::TooManyWinners)?; diff --git a/substrate/primitives/npos-elections/src/lib.rs b/substrate/primitives/npos-elections/src/lib.rs index 96af46e30f63..cfd8bc1d6656 100644 --- a/substrate/primitives/npos-elections/src/lib.rs +++ b/substrate/primitives/npos-elections/src/lib.rs @@ -247,6 +247,9 @@ pub struct Candidate { elected: bool, /// The round index at which this candidate was elected. round: usize, + /// A list of included backers for this candidate. This can be used to control the bounds of + /// maximum backers per candidate. + bounded_backers: Vec, } impl Candidate { @@ -269,6 +272,8 @@ pub struct Edge { candidate: CandidatePtr, /// The weight (i.e. stake given to `who`) of this edge. weight: ExtendedBalance, + /// Skips this edge. + skip: bool, } #[cfg(test)] @@ -276,14 +281,14 @@ impl Edge { fn new(candidate: Candidate, weight: ExtendedBalance) -> Self { let who = candidate.who.clone(); let candidate = Rc::new(RefCell::new(candidate)); - Self { weight, who, candidate, load: Default::default() } + Self { weight, who, candidate, load: Default::default(), skip: false } } } #[cfg(feature = "std")] impl core::fmt::Debug for Edge { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "Edge({:?}, weight = {:?})", self.who, self.weight) + write!(f, "Edge({:?}, weight = {:?}, skip = {})", self.who, self.weight, self.skip) } } @@ -556,6 +561,7 @@ pub fn setup_inputs( backed_stake: Default::default(), elected: Default::default(), round: Default::default(), + bounded_backers: Default::default(), } .to_ptr() }) @@ -580,6 +586,7 @@ pub fn setup_inputs( candidate: Rc::clone(&candidates[*idx]), load: Default::default(), weight: Default::default(), + skip: false, }); } // else {} would be wrong votes. We don't really care about it. } diff --git a/substrate/primitives/npos-elections/src/mock.rs b/substrate/primitives/npos-elections/src/mock.rs index 91757404145f..a94803367fb4 100644 --- a/substrate/primitives/npos-elections/src/mock.rs +++ b/substrate/primitives/npos-elections/src/mock.rs @@ -311,6 +311,7 @@ pub(crate) fn run_and_compare( voters: Vec<(AccountId, Vec)>, stake_of: FS, to_elect: usize, + max_backers_candidate: Option, ) where Output: PerThing128, FS: Fn(&AccountId) -> VoteWeight, @@ -323,6 +324,7 @@ pub(crate) fn run_and_compare( .iter() .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), + max_backers_candidate, None, ) .unwrap(); diff --git a/substrate/primitives/npos-elections/src/phragmen.rs b/substrate/primitives/npos-elections/src/phragmen.rs index f331152e722a..c6c2246244ae 100644 --- a/substrate/primitives/npos-elections/src/phragmen.rs +++ b/substrate/primitives/npos-elections/src/phragmen.rs @@ -71,11 +71,13 @@ pub fn seq_phragmen( to_elect: usize, candidates: Vec, voters: Vec<(AccountId, VoteWeight, impl IntoIterator)>, + max_backers_per_candidate: Option, balancing: Option, ) -> Result, crate::Error> { let (candidates, voters) = setup_inputs(candidates, voters); - let (candidates, mut voters) = seq_phragmen_core::(to_elect, candidates, voters)?; + let (candidates, mut voters) = + seq_phragmen_core::(to_elect, candidates, voters, max_backers_per_candidate)?; if let Some(ref config) = balancing { // NOTE: might create zero-edges, but we will strip them again when we convert voter into @@ -118,6 +120,7 @@ pub fn seq_phragmen_core( to_elect: usize, candidates: Vec>, mut voters: Vec>, + max_backers_per_candidate: Option, ) -> Result<(Vec>, Vec>), crate::Error> { // we have already checked that we have more candidates than minimum_candidate_count. let to_elect = to_elect.min(candidates.len()); @@ -138,10 +141,21 @@ pub fn seq_phragmen_core( } } - // loop 2: increment score - for voter in &voters { - for edge in &voter.edges { + // loop 2: increment score and the included backers of a candidate. + for voter in &mut voters { + for edge in &mut voter.edges { let mut candidate = edge.candidate.borrow_mut(); + + if (candidate.bounded_backers.len() as u32) >= + max_backers_per_candidate.unwrap_or(Bounded::max_value()) && + !candidate.bounded_backers.contains(&voter.who) + { + // if the candidate has reached max backers and the voter is not part of the + // bounded backers, taint the edge with skip and continue. + edge.skip = true; + continue + } + if !candidate.elected && !candidate.approval_stake.is_zero() { let temp_n = multiply_by_rational_with_rounding( voter.load.n(), @@ -153,6 +167,7 @@ pub fn seq_phragmen_core( let temp_d = voter.load.d(); let temp = Rational128::from(temp_n, temp_d); candidate.score = candidate.score.lazy_saturating_add(temp); + candidate.bounded_backers.push(voter.who.clone()); } } } @@ -183,6 +198,11 @@ pub fn seq_phragmen_core( // update backing stake of candidates and voters for voter in &mut voters { for edge in &mut voter.edges { + if edge.skip { + // skip this edge as its candidate has already reached max backers. + continue + } + if edge.candidate.borrow().elected { // update internal state. edge.weight = multiply_by_rational_with_rounding( diff --git a/substrate/primitives/npos-elections/src/pjr.rs b/substrate/primitives/npos-elections/src/pjr.rs index 6e3775199a21..a807aa740754 100644 --- a/substrate/primitives/npos-elections/src/pjr.rs +++ b/substrate/primitives/npos-elections/src/pjr.rs @@ -294,6 +294,8 @@ fn prepare_pjr_input( score: Default::default(), approval_stake: Default::default(), round: Default::default(), + // TODO: check if we need to pass the bounds here. + bounded_backers: supports.iter().map(|(a, _)| a).cloned().collect(), } .to_ptr() }) @@ -324,6 +326,7 @@ fn prepare_pjr_input( candidate: Rc::clone(&candidates[*idx]), weight, load: Default::default(), + skip: false, }); } } @@ -402,6 +405,7 @@ mod tests { score: Default::default(), approval_stake: Default::default(), round: Default::default(), + bounded_backers: Default::default(), } }) .collect::>(); @@ -412,6 +416,7 @@ mod tests { weight: c.backed_stake, candidate: c.to_ptr(), load: Default::default(), + skip: false, }) .collect::>(); voter.edges = edges; @@ -454,6 +459,7 @@ mod tests { approval_stake: Default::default(), backed_stake: Default::default(), round: Default::default(), + bounded_backers: Default::default(), } .to_ptr(); let score = pre_score(unelected, &vec![v1, v2, v3], 15); diff --git a/substrate/primitives/npos-elections/src/tests.rs b/substrate/primitives/npos-elections/src/tests.rs index 72ae9a0222be..416168efcde1 100644 --- a/substrate/primitives/npos-elections/src/tests.rs +++ b/substrate/primitives/npos-elections/src/tests.rs @@ -101,7 +101,7 @@ fn phragmen_core_poc_works() { let voters = vec![(10, 10, vec![1, 2]), (20, 20, vec![1, 3]), (30, 30, vec![2, 3])]; let (candidates, voters) = setup_inputs(candidates, voters); - let (candidates, voters) = seq_phragmen_core(2, candidates, voters).unwrap(); + let (candidates, voters) = seq_phragmen_core(2, candidates, voters, None).unwrap(); assert_eq!( voters @@ -141,7 +141,7 @@ fn balancing_core_works() { ]; let (candidates, voters) = setup_inputs(candidates, voters); - let (candidates, mut voters) = seq_phragmen_core(4, candidates, voters).unwrap(); + let (candidates, mut voters) = seq_phragmen_core(4, candidates, voters, None).unwrap(); let config = BalancingConfig { iterations: 4, tolerance: 0 }; let iters = balancing::balance::(&mut voters, &config); @@ -236,6 +236,7 @@ fn phragmen_poc_works() { .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), None, + None, ) .unwrap(); @@ -277,6 +278,50 @@ fn phragmen_poc_works() { ); } +#[test] +fn phragmen_poc_works_with_max_backers_per_candidate() { + let candidates = vec![1, 2, 3]; + let voters = vec![(10, vec![1, 2]), (20, vec![1, 2, 3]), (30, vec![1, 2, 3])]; + let stake_of = create_stake_of(&[(10, 10), (20, 20), (30, 30)]); + + let run_election = |max_backers: Option| { + let ElectionResult::<_, Perbill> { winners: _, assignments } = seq_phragmen( + 3, + candidates.clone(), + voters + .iter() + .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) + .collect::>(), + max_backers, + None, + ) + .unwrap(); + + let staked = assignment_ratio_to_staked(assignments, &stake_of); + to_support_map::(&staked) + }; + + let with_unbounded_backers = run_election(None); + + assert_eq!(with_unbounded_backers.get(&1).unwrap().voters.len(), 3); + assert_eq!(with_unbounded_backers.get(&2).unwrap().voters.len(), 3); + assert_eq!(with_unbounded_backers.get(&3).unwrap().voters.len(), 2); + + // max 2 backers per candidate. + let with_bounded_backers = run_election(Some(2)); + + assert_eq!(with_bounded_backers.get(&1).unwrap().voters.len(), 2); + assert_eq!(with_bounded_backers.get(&2).unwrap().voters.len(), 2); + assert_eq!(with_bounded_backers.get(&3).unwrap().voters.len(), 2); + + // max 1 backers per candidate. + let with_bounded_backers = run_election(Some(1)); + + assert_eq!(with_bounded_backers.get(&1).unwrap().voters.len(), 1); + assert_eq!(with_bounded_backers.get(&2).unwrap().voters.len(), 1); + assert_eq!(with_bounded_backers.get(&3).unwrap().voters.len(), 1); +} + #[test] fn phragmen_poc_works_with_balancing() { let candidates = vec![1, 2, 3]; @@ -291,6 +336,7 @@ fn phragmen_poc_works_with_balancing() { .iter() .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), + None, Some(config), ) .unwrap(); @@ -340,10 +386,10 @@ fn phragmen_poc_2_works() { let stake_of = create_stake_of(&[(10, 1000), (20, 1000), (30, 1000), (40, 1000), (2, 500), (4, 500)]); - run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2); - run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2); - run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2); - run_and_compare::(candidates, voters, &stake_of, 2); + run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2, None); + run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2, None); + run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2, None); + run_and_compare::(candidates, voters, &stake_of, 2, None); } #[test] @@ -352,10 +398,10 @@ fn phragmen_poc_3_works() { let voters = vec![(2, vec![10, 20, 30]), (4, vec![10, 20, 40])]; let stake_of = create_stake_of(&[(10, 1000), (20, 1000), (30, 1000), (2, 50), (4, 1000)]); - run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2); - run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2); - run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2); - run_and_compare::(candidates, voters, &stake_of, 2); + run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2, None); + run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2, None); + run_and_compare::(candidates.clone(), voters.clone(), &stake_of, 2, None); + run_and_compare::(candidates, voters, &stake_of, 2, None); } #[test] @@ -379,6 +425,7 @@ fn phragmen_accuracy_on_large_scale_only_candidates() { .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), None, + None, ) .unwrap(); @@ -410,6 +457,7 @@ fn phragmen_accuracy_on_large_scale_voters_and_candidates() { .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), None, + None, ) .unwrap(); @@ -442,6 +490,7 @@ fn phragmen_accuracy_on_small_scale_self_vote() { .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), None, + None, ) .unwrap(); @@ -472,6 +521,7 @@ fn phragmen_accuracy_on_small_scale_no_self_vote() { .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), None, + None, ) .unwrap(); @@ -508,6 +558,7 @@ fn phragmen_large_scale_test() { .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), None, + None, ) .unwrap(); @@ -535,6 +586,7 @@ fn phragmen_large_scale_test_2() { .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), None, + None, ) .unwrap(); @@ -587,7 +639,7 @@ fn phragmen_linear_equalize() { (130, 1000), ]); - run_and_compare::(candidates, voters, &stake_of, 2); + run_and_compare::(candidates, voters, &stake_of, 2, None); } #[test] @@ -604,6 +656,7 @@ fn elect_has_no_entry_barrier() { .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), None, + None, ) .unwrap(); @@ -625,6 +678,7 @@ fn phragmen_self_votes_should_be_kept() { .map(|(ref v, ref vs)| (*v, stake_of(v), vs.clone())) .collect::>(), None, + None, ) .unwrap(); @@ -664,7 +718,7 @@ fn duplicate_target_is_ignored() { let voters = vec![(10, 100, vec![1, 1, 2, 3]), (20, 100, vec![2, 3]), (30, 50, vec![1, 1, 2])]; let ElectionResult::<_, Perbill> { winners, assignments } = - seq_phragmen(2, candidates, voters, None).unwrap(); + seq_phragmen(2, candidates, voters, None, None).unwrap(); assert_eq!(winners, vec![(2, 140), (3, 110)]); assert_eq!( @@ -682,7 +736,7 @@ fn duplicate_target_is_ignored_when_winner() { let voters = vec![(10, 100, vec![1, 1, 2, 3]), (20, 100, vec![1, 2])]; let ElectionResult::<_, Perbill> { winners, assignments } = - seq_phragmen(2, candidates, voters, None).unwrap(); + seq_phragmen(2, candidates, voters, None, None).unwrap(); assert_eq!(winners, vec![(1, 100), (2, 100)]); assert_eq!(