frame_plugins/elections.rs
1// SPDX-License-Identifier: MPL-2.0
2//
3// Part of Auguth Labs open-source softwares.
4// Built for the Substrate framework.
5//
6// This Source Code Form is subject to the terms of the Mozilla Public
7// License, v. 2.0. If a copy of the MPL was not distributed with this
8// file, You can obtain one at https://mozilla.org/MPL/2.0/.
9//
10// Copyright (c) 2026 Auguth Labs (OPC) Pvt Ltd, India
11
12// ===============================================================================
13// ``````````````````````````````` ELECTION PLUGINS ``````````````````````````````
14// ===============================================================================
15
16//! Defines two distinct **pluggable election models** for ranking and selecting
17//! candidates based on their stake, backing, or influence metrics.
18//!
19//! Elections are abstracted into two main models:
20//!
21//! ## Flat Election (`flat`)
22//!
23//! - Aggregates all contributions (including the candidates's own stake-if any)
24//! into a single scalar metric.
25//! - Candidates are compared using their **flattened total weight**.
26//! - Useful for scenarios where **every unit of support counts equally**
27//! and simple proportionality is desired.
28//!
29//! ## Fair Election (`fair`)
30//!
31//! - Each backer's contribution is **kept unaggregated** (including the
32//! candidate's own stake-if any as one of the backing), preserving individual
33//! influence granularity.
34//! - Useful when the goal is to **prevent candidates from dominating through
35//! self-funding** and emphasize external support.
36//!
37//! ## Purpose
38//!
39//! Separating election models into `flat` and `fair` provides flexibility:
40//! - **Flat** for simple, proportional elections where total stake matters.
41//! - **Fair** for more security-conscious or governance-focused elections
42//! emphasizing community support.
43//!
44//! Both models implement a **pluggable algorithm interface**, enabling runtime
45//! substitution, testing of different strategies, and easy extension with new
46//! election rules.
47
48// ===============================================================================
49// ```````````````````````````` FLAT-ELECTION PLUGINS ````````````````````````````
50// ===============================================================================
51
52pub use flat::*;
53
54/// Contains **FlatElection plugin models**, which rank entities using a
55/// **single aggregated scalar (flat weight)** computed from a list of entities and
56/// their corresponding weights.
57///
58/// In this model:
59/// - Each entity is represented as a `(entity, flat_weight)` pair.
60/// - Individual contributions are **not tracked separately**; only the total flat
61/// weight per entity matters.
62/// - The plugin takes the list of entities with their flat weights and **produces
63/// a ranked output**.
64///
65/// ## Characteristics
66/// - Requires a **list of entities with their flat weights** as input.
67/// - Produces a **sorted list of entities** according to their flat weight.
68/// - Ignores the structure or distribution of contributions, focusing purely on the
69/// aggregated value.
70///
71/// ## Example Flow
72/// 1. Prepare a list of entities with their flat weights:
73/// `[(entity1, w1), (entity2, w2), ...]`.
74/// 2. Pass this list to a FlatElection plugin model.
75/// 3. The model sorts and outputs entities in **descending order of flat weight**.
76pub mod flat {
77
78 // ===============================================================================
79 // ``````````````````````````````````` IMPORTS ```````````````````````````````````
80 // ===============================================================================
81
82 // --- FRAME Suite ---
83 use frame_suite::plugin_model;
84
85 // --- Substrate primitives ---
86 use sp_runtime::Vec;
87
88 // ===============================================================================
89 // ```````````````````````````` TOP-DOWN FLAT-ELECTION ```````````````````````````
90 // ===============================================================================
91
92 plugin_model! {
93 /// The **TopDownFlat** model ranks candidates based solely on their **aggregated
94 /// flat weight**. Each candidate's score represents the total support or stake
95 /// they possess.
96 ///
97 /// **Concept**: **Total-Weight Dominance**
98 ///
99 /// The resulting order is strictly determined by these scalar totals - higher
100 /// weights dominate, ensuring proportional fairness where every unit of influence
101 /// counts equally.
102 ///
103 /// ## Characteristics:
104 /// - **Simple aggregation**: Treats all contributions as additive and equivalent.
105 /// - **Self-inclusive**: Candidate's own weight contributes to their score.
106 /// - **Deterministic ordering**: Sorted descending by total flat weight.
107 /// - **Context-free**: Requires no external context or normalization.
108 ///
109 /// ## Applications:
110 /// - **Stake-based elections**
111 /// - **Token-weighted voting**
112 /// - **Simple ranking systems** where total influence determines outcome
113 ///
114 /// ## Example:
115 /// ```ignore
116 /// let input = vec![
117 /// ("Alice", 90),
118 /// ("Bob", 50),
119 /// ("Charlie", 80),
120 /// ];
121 /// let output = TopDownFlat::compute(input, None);
122 /// assert_eq!(output, vec!["Alice", "Charlie", "Bob"]);
123 /// ```
124 ///
125 /// ## References
126 /// - [Sorting - Wikipedia](https://en.wikipedia.org/wiki/Sorting)
127 name: pub TopDownFlatModel,
128 input: Input,
129 output: Output,
130 others: [Candidate, FlatWeight],
131 bounds: [
132 Input: FromIterator<(Candidate, FlatWeight)>
133 + IntoIterator<Item = (Candidate, FlatWeight)> + Clone,
134 Output: FromIterator<Candidate>,
135 FlatWeight: Ord + Clone,
136 Candidate: Clone,
137 ],
138 /// Computes the **descending rank** of candidates by total flat weight.
139 ///
140 /// 1. Collects all `(Candidate, FlatWeight)` pairs.
141 /// 2. Sorts them in **descending order** of their weight.
142 /// 3. Returns the ordered list of candidates.
143 compute: |input, _context| {
144 // Step 1: Collect input into a mutable vector
145 let mut items: Vec<(Candidate, FlatWeight)> = Vec::new();
146 for pair in input.clone() {
147 items.push(pair);
148 }
149
150 // Step 2: Sort items in descending order by FlatWeight
151 items.sort_by(|a, b| b.1.cmp(&a.1));
152
153 // Step 3: Extract only the candidates in order
154 let mut output: Vec<Candidate> = Vec::new();
155 for (candidate, _) in items {
156 output.push(candidate);
157 }
158
159 // Step 4: Return candidates as iterator
160 output.into_iter().collect()
161 }
162 }
163
164 // ===============================================================================
165 // ``````````````````````````` THRESHOLD FLAT-ELECTION ```````````````````````````
166 // ===============================================================================
167
168 /// Context Config for Plugin Model [`ThresholdFlatModel`]
169 ///
170 /// This struct provides the configuration for threshold-based elections, where
171 /// only candidates whose weight meets or exceeds a specified threshold are
172 /// considered.
173 pub struct ThresholdFlatModelConfig<T> {
174 /// The minimum weight required for a candidate to be included in the output.
175 ///
176 /// - Candidates with a weight **greater than or equal to** this value will be
177 /// retained.
178 /// - Candidates with a weight **below** this value will be filtered out.
179 ///
180 /// ## Example
181 /// ```ignore
182 /// let config = ThresholdModelConfig { threshold: 50 };
183 /// // Only candidates with weight >= 50 will be returned by the model
184 /// ```
185 pub threshold: T,
186 }
187
188 plugin_model! {
189 /// The **ThresholdFlat** model filters candidates based on a **minimum flat
190 /// weight threshold**. Only candidates whose weight meets or exceeds the threshold
191 /// are included in the output.
192 ///
193 /// **Concept**: **Minimum Weight Filtering**
194 ///
195 /// This is useful in elections or ranking systems where very low-contributing
196 /// candidates should be excluded from consideration.
197 ///
198 /// ## Characteristics:
199 /// - **Threshold-based**: Filters out candidates below a specified weight.
200 /// - **Self-inclusive**: Candidate's own weight counts toward the threshold.
201 /// - **Deterministic ordering**: Relative ordering of remaining candidates is
202 /// preserved from the input (or can be further sorted in downstream models).
203 /// - **Context-driven**: Uses an external [`ThresholdModelConfig`] struct to
204 /// provide the threshold.
205 ///
206 /// ## Applications:
207 /// - Removing candidates with insufficient stake or support.
208 /// - Pre-filtering before proportional or ranked elections.
209 /// - Reward distribution where a minimum contribution is required.
210 ///
211 /// ## Example:
212 /// ```ignore
213 /// let input = vec![
214 /// ("Alice", 90),
215 /// ("Bob", 50),
216 /// ("Charlie", 30),
217 /// ];
218 /// let config = ThresholdModelConfig { threshold: 50 };
219 /// let output = ThresholdModel::compute(input, Some(config));
220 /// assert_eq!(output, vec!["Alice", "Bob"]);
221 /// ```
222 ///
223 /// ## References:
224 /// - [Thresholding and Filtering - Wikipedia](https://en.wikipedia.org/wiki/Thresholding)
225 /// - Standard minimum weight / eligibility filtering in proportional systems.
226 name: pub ThresholdFlatModel,
227 input: Input,
228 output: Output,
229 others: [Candidate, FlatWeight],
230 context: ThresholdFlatModelConfig<FlatWeight>, // external struct
231 bounds: [
232 Input: IntoIterator<Item = (Candidate, FlatWeight)> + Clone,
233 Output: FromIterator<Candidate>,
234 FlatWeight: Ord + Clone,
235 Candidate: Clone,
236 ],
237 /// Filters candidates below a configurable flat weight threshold.
238 ///
239 /// - Iterates over all `(Candidate, FlatWeight)` pairs.
240 /// - Keeps only candidates where weight >= `ctx.threshold`.
241 /// - Returns an iterator over the remaining candidates.
242 compute: |input, ctx| {
243 // Step 1: Collect input into a mutable vector (optional, ensures
244 // we can iterate multiple times if needed)
245 let items: Vec<(Candidate, FlatWeight)> = input.clone().into_iter().collect();
246
247 // Step 2: Prepare an output vector to store candidates that meet the threshold
248 let mut output: Vec<Candidate> = Vec::new();
249
250 // Step 3: Iterate over each candidate and weight
251 for (candidate, weight) in items {
252 // Step 4: Check if the weight meets or exceeds the threshold
253 if weight >= ctx.threshold.clone() {
254 // Step 5: If yes, add the candidate to the output
255 output.push(candidate);
256 }
257 }
258
259 // Step 6: Return the output as an iterator (FromIterator)
260 output.into_iter().collect()
261 }
262 }
263}
264
265// ===============================================================================
266// ```````````````````````````` FAIR-ELECTION PLUGINS ````````````````````````````
267// ===============================================================================
268
269pub use fair::*;
270
271/// Contains **FairElection plugin models**, which rank entities using a
272/// **fair weight**, where each entity's weight is derived from a **list of
273/// contributors** and their individual contributions.
274///
275/// In this model:
276/// - Each entity is represented as `(entity, fair_weight)`.
277/// - The `fair_weight` itself is a **collection of `(contributor, contribution)`
278/// pairs**.
279/// - The plugin aggregates or evaluates these individual contributions according
280/// to its algorithm to produce a ranked output.
281///
282/// ## Characteristics
283/// - Requires a **list of entities with their fair weights** as input.
284/// - Each fair weight preserves the **granularity of individual contributions**,
285/// unlike flat weights.
286/// - Produces a **sorted list of entities**, taking into account the distribution and
287/// magnitude of contributions.
288/// - Ideal for scenarios where **fairness or proportional representation** matters,
289/// rather than just total aggregated value.
290///
291/// ## Example Flow
292/// 1. Prepare a list of entities with their fair weights:
293/// `[(entity1, [(c1, v1), (c2, v2)]), ...]`.
294/// 2. Pass this list to a FairElection plugin model.
295/// 3. The model computes a ranking based on the **individual contributions within
296/// each fair weight** and outputs entities in order.
297pub mod fair {
298
299 // ===============================================================================
300 // ````````````````````````````````` IMPORTS `````````````````````````````````````
301 // ===============================================================================
302
303 // --- FRAME Suite ---
304 use frame_suite::plugin_model;
305
306 // --- Substrate primitives ---
307 use sp_runtime::{
308 traits::{Saturating, Zero},
309 Vec,
310 };
311
312 // --- Substrate std (no_std helpers) ---
313 use sp_std::ops::{Add, Div, Mul};
314
315 // ===============================================================================
316 // ``````````````````````````` TOP-DOWN FAIR-ELECTION ````````````````````````````
317 // ===============================================================================
318
319 plugin_model! {
320 /// The **TopDownFair** model evaluates candidates based on the **aggregate
321 /// support** they receive from **external backers**.
322 ///
323 /// **Concept**: **Aggregated External Fairness**
324 ///
325 /// Each candidate has a list of `(Backer, Backed)` pairs representing
326 /// individual endorsements. The model sums these contributions using
327 /// **saturating addition** to prevent overflow and sorts candidates
328 /// in **descending order** by their total received backing.
329 ///
330 /// ## Characteristics:
331 /// - **Fair aggregation**: Each candidate's score is the sum of their
332 /// backers' support.
333 /// - **No self-weighting**: Excludes self-stake to prevent dominance
334 /// by self-funding.
335 /// - **Overflow-safe**: Uses `Saturating` arithmetic to avoid overflow
336 /// errors.
337 /// - **Context-free**: Deterministic and stateless ranking.
338 ///
339 /// ## Applications:
340 /// - **Governance elections** prioritizing external community support
341 /// - **Fair staking models** with anti-self-dealing constraints
342 /// - **Collaborative credit or delegation-based systems**
343 ///
344 /// ## Example:
345 /// ```ignore
346 /// let input = vec![
347 /// ("Alice", vec![("Bob", 10), ("Carol", 20)]),
348 /// ("Dave", vec![("Eve", 15), ("Frank", 15)]),
349 /// ("Grace", vec![("Heidi", 5), ("Ivan", 10)]),
350 /// ];
351 /// let output = TopDownFair::compute(input, None);
352 /// // Aggregated totals:
353 /// // Alice: 30, Dave: 30, Grace: 15
354 /// assert_eq!(output, vec!["Alice", "Dave", "Grace"]);
355 /// ```
356 ///
357 /// ## Reference:
358 /// - [Weighted voting - Wikipedia](https://en.wikipedia.org/wiki/Weighted_voting)
359 /// - [Delegated voting / Liquid democracy](https://en.wikipedia.org/wiki/Liquid_democracy)
360 /// - Osborne, M. J., *An Introduction to Game Theory*, Oxford University Press, 2004 (proportional influence)
361 name: pub TopDownFairModel,
362 input: Input,
363 output: Output,
364 others: [Candidate, FairWeight, Backer, Backed],
365 bounds: [
366 Input: FromIterator<(Candidate, FairWeight)>
367 + IntoIterator<Item = (Candidate, FairWeight)> + Clone,
368 Output: FromIterator<Candidate>,
369 FairWeight: FromIterator<(Backer, Backed)>
370 + IntoIterator<Item = (Backer, Backed)> + Clone,
371 Backed: Clone + Ord + Zero + Saturating,
372 Candidate: Clone,
373 ],
374 /// Computes the **fair aggregated rank** of candidates imperatively.
375 ///
376 /// - Iterates over each candidate and sums their backers' contributions
377 /// using saturating addition.
378 /// - Sorts candidates by descending total backing.
379 /// - Returns only the ordered list of candidates.
380 compute: |input, _context| {
381 // Step 1: Collect input into a vector for processing
382 let items_input: Vec<(Candidate, FairWeight)> = input.clone().into_iter().collect();
383
384 // Step 2: Prepare vector to store total backing for each candidate
385 let mut items: Vec<(Candidate, Backed)> = Vec::new();
386
387 // Step 3: Sum external backing for each candidate
388 for (candidate, fairweight) in items_input {
389 let mut total = Backed::zero();
390 for (_, backed) in fairweight {
391 total = total.saturating_add(backed);
392 }
393 items.push((candidate, total));
394 }
395
396 // Step 4: Sort candidates by descending total backing
397 items.sort_by(|a, b| b.1.cmp(&a.1));
398
399 // Step 5: Collect only the candidates in order
400 let mut output: Vec<Candidate> = Vec::new();
401 for (candidate, _) in items {
402 output.push(candidate);
403 }
404
405 output.into_iter().collect()
406 }
407 }
408
409 // ===============================================================================
410 // ``````````````````````````` BALANCED FAIR-ELECTION ````````````````````````````
411 // ===============================================================================
412
413 plugin_model! {
414 /// The **BalancedFair** model evaluates candidates by combining both the
415 /// **total backing** and the **average backing per backer**. This approach
416 /// balances candidates who have a few very large backers versus those with
417 /// many smaller backers.
418 ///
419 /// **Concept**: **Total + Average External Fairness**
420 ///
421 /// Each candidate has a list of `(Backer, Backed)` pairs.
422 /// The model computes:
423 /// 1. `total = sum of all Backed values`
424 /// 2. `average = total / number of backers`
425 /// 3. `score = total + average`
426 ///
427 /// Candidates are then sorted in descending order by this combined score.
428 ///
429 /// ## Characteristics:
430 /// - **Balanced aggregation**: Rewards both high total support and broad
431 /// distribution.
432 /// - **No self-weighting**: Excludes candidate self-contributions.
433 /// - **Overflow-safe**: Uses saturating arithmetic.
434 /// - **Deterministic ordering**: Sorted descending by combined score.
435 /// - **Context-free**: Stateless and reproducible ranking.
436 ///
437 /// ## Applications:
438 /// - Governance systems balancing "big backers" vs "broad support".
439 /// - Collaborative projects or token-based delegation systems.
440 /// - Reward allocation where both total and average contributions matter.
441 ///
442 /// ## References:
443 /// - [Weighted voting - Wikipedia](https://en.wikipedia.org/wiki/Weighted_voting)
444 /// - [Delegated voting / Liquid democracy](https://en.wikipedia.org/wiki/Liquid_democracy)
445 /// - Osborne, M. J., *An Introduction to Game Theory*, Oxford University Press, 2004
446 name: pub BalancedModel,
447 input: Input,
448 output: Output,
449 others: [Candidate, FairWeight, Backer, Backed],
450 bounds: [
451 Input: IntoIterator<Item = (Candidate, FairWeight)> + Clone,
452 Output: FromIterator<Candidate>,
453 FairWeight: IntoIterator<Item = (Backer, Backed)> + Clone,
454 Backed: Clone + Ord + Zero + Saturating + Add<Output = Backed> + Div<usize, Output = Backed>,
455 Candidate: Clone,
456 ],
457 /// Computes the balanced fair rank of candidates imperatively.
458 ///
459 /// Steps:
460 /// 1. Iterate over each candidate and collect all `(Backer, Backed)` pairs.
461 /// 2. Compute total backing as the saturating sum of `Backed`.
462 /// 3. Compute average backing per backer.
463 /// 4. Compute score = total + average.
464 /// 5. Sort candidates by descending score.
465 /// 6. Return only the ordered candidates.
466 compute: |input, _context| {
467 // Step 1: Collect input into a vector for processing
468 let items_input: Vec<(Candidate, FairWeight)> = input.clone().into_iter().collect();
469
470 // Step 2: Prepare vector to store scores for each candidate
471 let mut scores: Vec<(Candidate, Backed)> = Vec::new();
472
473 // Step 3: Compute total, average, and combined score for each candidate
474 for (candidate, fairweight) in items_input {
475 let entries: Vec<(Backer, Backed)> = fairweight.into_iter().collect();
476 // avoid division by zero
477 let count = if entries.is_empty() { 1 } else { entries.len() };
478
479 let mut total = Backed::zero();
480 for (_, value) in &entries {
481 total = total.saturating_add(value.clone());
482 }
483
484 let average = total.clone() / count;
485 let score = total.saturating_add(average);
486
487 scores.push((candidate, score));
488 }
489
490 // Step 4: Sort candidates by descending score
491 scores.sort_by(|a, b| b.1.cmp(&a.1));
492
493 // Step 5: Collect only candidates in order
494 let mut output: Vec<Candidate> = Vec::new();
495 for (candidate, _) in scores {
496 output.push(candidate);
497 }
498
499 output.into_iter().collect()
500 }
501 }
502
503 // ===============================================================================
504 // ``````````````````````````` PHRAGMEN FAIR-ELECTION ````````````````````````````
505 // ===============================================================================
506
507 /// Configuration for the [`PhragmenModel`] plugin model
508 ///
509 /// This struct allows fine-tuning of the Phragmen election algorithm
510 /// to produce different variants of fair elections, including weighted,
511 /// sequential, and scaled variants.
512 ///
513 /// Each field modifies how candidates are evaluated and how backer loads
514 /// are computed.
515 ///
516 /// ## References:
517 /// - [Phragmen voting - Wikipedia](https://en.wikipedia.org/wiki/Phragmen%27s_voting_rules)
518 /// - Enestrom, E. "Mathematical Theory of Proportional Representation", 1896
519 /// - Thiele, T. N., "Proportional Representation in Elections", 1895
520 pub struct PhragmenModelConfig<Backed> {
521 /// `weighted`: If true, the algorithm **scales each backer's contribution** by their
522 /// influence or stake.
523 ///
524 /// - **Behavior when true:** Candidates supported by high-influence backers contribute more
525 /// to the backers' load. The algorithm prioritizes minimizing load while respecting
526 /// weighted influence, meaning candidates with concentrated high-weight backers may be
527 /// elected later to balance maximum loads.
528 /// - **Behavior when false:** All backers' contributions are treated equally. Only the sum
529 /// of contributions matters, ignoring stake or influence differences.
530 pub weighted: bool,
531
532 /// `scale`: Optional multiplier applied to backer contributions when `weighted` is true.
533 ///
534 /// - **Behavior when Some(value):** Each backer's contribution is multiplied by `scale`.
535 /// This allows amplifying or reducing the effective weight of all backers, controlling
536 /// how heavily influence/stake impacts candidate ranking.
537 /// - **Behavior when None:** Contributions are used as-is, without additional scaling.
538 pub scale: Option<Backed>,
539 }
540
541 plugin_model! {
542 /// The **Phragmen** model ranks candidates based on fair distribution of
543 /// voter/backer load. Each candidate has `(Backer, Backed)` pairs representing
544 /// support, and the algorithm selects candidates sequentially to **minimize
545 /// the maximum load** among all backers.
546 ///
547 /// **Concept**: **Proportional Load Balancing**
548 ///
549 /// This plugin supports multiple Phragmen variants through the plugin
550 /// context [`PhragmenModelConfig`]:
551 /// - Weighted vs unweighted contributions
552 /// - Deterministic tie-breaking
553 /// - Optional scaling of backer contributions
554 ///
555 /// ## Applications:
556 /// - Governance elections with proportional representation
557 /// - Token or stake weighted systems
558 /// - Delegation or liquid democracy systems
559 ///
560 /// ## References:
561 /// - [Phragmen voting - Wikipedia](https://en.wikipedia.org/wiki/Phragmen%27s_voting_rules)
562 /// - Enestrom, E. "Mathematical Theory of Proportional Representation", 1896
563 /// - Thiele, T. N., "Proportional Representation in Elections", 1895
564 name: pub PhragmenModel,
565 input: Input,
566 output: Output,
567 others: [Candidate, FairWeight, Backer, Backed],
568 context: PhragmenModelConfig<Backed>,
569 bounds: [
570 Input: IntoIterator<Item = (Candidate, FairWeight)> + Clone,
571 Output: FromIterator<Candidate>,
572 FairWeight: IntoIterator<Item = (Backer, Backed)> + Clone,
573 Backed: Clone + Zero + Add<Output = Backed> + Ord + Div<usize, Output = Backed> + Mul<Backed, Output = Backed>,
574 Candidate: Clone + Eq,
575 Backer: Clone + Eq + sp_std::hash::Hash + Ord,
576 ],
577
578 /// Computes the **Phragmen ranking** of candidates imperatively.
579 ///
580 /// Steps:
581 /// 1. Initialize all backer loads to zero.
582 /// 2. While candidates remain:
583 /// - Compute maximum load increase for each candidate if elected.
584 /// - Apply weighting/scaling if configured.
585 /// - Deterministically select candidate with minimal max load (tie = first in list).
586 /// - Update ranking and backer loads.
587 /// 3. Return all candidates in ranked order.
588 compute: |input, ctx| {
589 use sp_std::collections::btree_map::BTreeMap;
590 use sp_std::vec::Vec;
591
592 // Step 1: collect candidates and initialize ranking
593 let mut remaining: Vec<(Candidate, FairWeight)> = input.clone().into_iter().collect();
594 let mut ranking: Vec<Candidate> = Vec::new();
595
596 // Step 1b: initialize all backer loads to zero
597 let mut load: BTreeMap<Backer, Backed> = BTreeMap::new();
598 for (_, fairweight) in &remaining {
599 for (backer, _) in fairweight.clone().into_iter() {
600 load.entry(backer).or_insert(Backed::zero());
601 }
602 }
603
604 // Step 2: select candidates until all are ranked
605 while !remaining.is_empty() {
606 let mut best_candidate = None;
607 let mut best_max_load = None;
608
609 for (candidate, fairweight) in &remaining {
610 // Compute maximum load this candidate would impose on their backers
611 let mut max_load = Backed::zero();
612
613 for (backer, backed) in fairweight.clone().into_iter() {
614 let mut contribution = backed;
615
616 // Apply weighting if configured
617 if ctx.weighted {
618 if let Some(scale) = &ctx.scale {
619 contribution = contribution * scale.clone();
620 }
621 }
622
623 let new_load = load.get(&backer).unwrap().clone() + contribution;
624 if new_load > max_load {
625 max_load = new_load;
626 }
627 }
628
629 // Determine if this candidate is currently the best choice
630 if best_max_load.is_none() || max_load < best_max_load.clone().unwrap() {
631 best_candidate = Some(candidate.clone());
632 best_max_load = Some(max_load);
633 }
634 // Tie is resolved deterministically by keeping first found
635 }
636
637 // Remove chosen candidate from remaining and add to ranking
638 let chosen_index = remaining
639 .iter()
640 .position(|(c, _)| *c == best_candidate.clone().unwrap())
641 .unwrap();
642 let (_candidate, fairweight) = remaining.remove(chosen_index);
643 ranking.push(best_candidate.unwrap());
644
645 // Step 2c: update backer loads
646 for (backer, backed) in fairweight.into_iter() {
647 let mut contribution = backed;
648 if ctx.weighted {
649 if let Some(scale) = &ctx.scale {
650 contribution = contribution * scale.clone();
651 }
652 }
653 let entry = load.entry(backer).or_insert(Backed::zero());
654 *entry = entry.clone() + contribution;
655 }
656 }
657
658 // Step 3: return ranked candidates
659 ranking.into_iter().collect()
660 }
661 }
662
663 // ===============================================================================
664 // ````````````````````````` MAX-MIN LOAD FAIR-ELECTION ``````````````````````````
665 // ===============================================================================
666
667 plugin_model! {
668 /// The **Max-Min Load Fair** model ranks candidates to achieve a **fair
669 /// distribution of influence among all backers**. It selects candidates
670 /// sequentially to **minimize the maximum load any backer bears**, ensuring
671 /// no single backer is disproportionately overrepresented.
672 ///
673 /// **Concept**: **Load-Balanced Candidate Selection**
674 ///
675 /// Each candidate is associated with a list of `(Backer, Backed)` pairs
676 /// representing the support they receive. The algorithm repeatedly:
677 /// 1. Computes, for each remaining candidate, the maximum load their
678 /// selection would impose on their backers.
679 /// 2. Selects the candidate whose election results in the **smallest
680 /// maximum load increase**.
681 /// 3. Updates backer loads accordingly.
682 ///
683 /// ## Characteristics:
684 /// - **Fairness-focused**: Prioritizes balancing backer loads rather
685 /// than simply maximizing totals.
686 /// - **Deterministic**: Tie-breaking is deterministic (first candidate
687 /// in iteration).
688 /// - **Context-free**: Does not require any external configuration or
689 /// parameters.
690 ///
691 /// ## Applications:
692 /// - Governance or board elections where proportional influence is desired.
693 /// - Delegation-based or collaborative voting systems.
694 /// - Any scenario where minimizing backer overload improves fairness.
695 name: pub MaxMinLoadModel,
696 input: Input,
697 output: Output,
698 others: [Candidate, Backer, Backed, FairWeight],
699 bounds: [
700 Input: IntoIterator<Item = (Candidate, FairWeight)> + Clone,
701 Output: FromIterator<Candidate>,
702 FairWeight: IntoIterator<Item = (Backer, Backed)> + Clone,
703 Backed: Clone + Zero + Ord + Add<Output = Backed>,
704 Candidate: Clone + Eq,
705 Backer: Clone + Eq + sp_std::hash::Hash + Ord,
706 ],
707 /// Computes a fair ranking of candidates by minimizing maximum load
708 /// per backer.
709 ///
710 /// Steps :
711 /// 1. **Collect input** into a vector for sequential processing.
712 /// 2. **Initialize backer loads** to zero.
713 /// 3. While there are unranked candidates:
714 /// a. For each candidate, compute the maximum load increase across
715 /// their backers.
716 /// b. Select the candidate with the smallest maximum load.
717 /// c. Remove the selected candidate from remaining candidates and
718 /// append to ranking.
719 /// d. Update backer loads by adding this candidate's contributions.
720 /// 4. Return the ranked candidates as an iterator.
721 compute: |input, _ctx| {
722 use sp_std::collections::btree_map::BTreeMap;
723 use sp_std::vec::Vec;
724
725 // Step 1: Collect candidates and their backing
726 let mut remaining: Vec<(Candidate, FairWeight)> = input.clone().into_iter().collect();
727 let mut ranking: Vec<Candidate> = Vec::new();
728
729 // Step 2: Initialize backer loads to zero
730 let mut load: BTreeMap<Backer, Backed> = BTreeMap::new();
731 for (_, fairweight) in &remaining {
732 for (backer, _) in fairweight.clone().into_iter() {
733 load.entry(backer).or_insert(Backed::zero());
734 }
735 }
736
737 // Step 3: Iteratively select candidates
738 while !remaining.is_empty() {
739 let mut best_candidate = None;
740 let mut min_max_load = None;
741
742 // Evaluate each remaining candidate
743 for (candidate, fairweight) in &remaining {
744 let mut max_load = Backed::zero();
745
746 for (backer, backed) in fairweight.clone().into_iter() {
747 let new_load = load.get(&backer).unwrap().clone() + backed;
748 if new_load > max_load {
749 max_load = new_load;
750 }
751 }
752
753 // Candidate minimizes maximum load
754 if min_max_load.is_none() || max_load < min_max_load.clone().unwrap() {
755 best_candidate = Some(candidate.clone());
756 min_max_load = Some(max_load);
757 }
758 }
759
760 // Remove selected candidate from remaining
761 let idx = remaining
762 .iter()
763 .position(|(c, _)| *c == best_candidate.clone().unwrap())
764 .unwrap();
765 let (_c, fairweight) = remaining.remove(idx);
766 ranking.push(best_candidate.unwrap());
767
768 // Update backer loads for selected candidate
769 for (backer, backed) in fairweight.into_iter() {
770 let entry = load.entry(backer).or_insert(Backed::zero());
771 *entry = entry.clone() + backed;
772 }
773 }
774
775 // Step 4: Return final ranking
776 ranking.into_iter().collect()
777 }
778 }
779
780 // ===============================================================================
781 // ``````````````````````````` THRESHOLD FAIR-ELECTION ```````````````````````````
782 // ===============================================================================
783
784 /// Defines the configuration for [`ThresholdFairModel`] election plugin.
785 ///
786 /// Candidates are filtered based on a minimum total backing before being
787 /// considered for ranking or selection.
788 pub struct ThresholdFairModelConfig<T> {
789 /// Minimum total backing required for a candidate to be considered.
790 /// - `threshold`: The minimum backing value a candidate must have
791 /// to be eligible.
792 /// - Candidates with total backing **less than `threshold` are
793 /// excluded** from the election.
794 /// - Useful for preventing candidates with negligible support
795 /// from affecting the outcome.
796 pub threshold: T,
797 }
798
799 plugin_model! {
800 /// Filters candidates whose **total backing** is below a configurable
801 /// threshold.
802 ///
803 /// Each candidate is represented as `(Candidate, FairWeight)` where
804 /// `FairWeight` is a list of `(Backer, Backed)` pairs. The algorithm
805 /// sums all backings for a candidate and includes only those whose
806 /// total meets or exceeds `ctx.threshold`.
807 ///
808 /// ## Characteristics:
809 /// - Context-driven via [`ThresholdModelConfig`].
810 /// - Deterministic ordering of candidates passing the threshold.
811 /// - Prevents candidates with insufficient support from participating.
812 ///
813 /// ## Use cases:
814 /// - Governance systems requiring minimum backing for eligibility.
815 /// - Stake-weighted elections where very low-supported candidates
816 /// should be ignored.
817 name: pub ThresholdFairModel,
818 input: Input,
819 output: Output,
820 others: [Candidate, Backer, Backed, FairWeight],
821 context: ThresholdFairModelConfig<Backed>,
822 bounds: [
823 Input: IntoIterator<Item = (Candidate, FairWeight)> + Clone,
824 Output: FromIterator<Candidate>,
825 FairWeight: IntoIterator<Item = (Backer, Backed)> + Clone,
826 Backed: Clone + Zero + Add<Output = Backed> + Ord,
827 Candidate: Clone,
828 Backer: Clone,
829 ],
830
831 /// Computes the filtered candidates imperatively.
832 ///
833 /// Steps:
834 /// 1. Initialize an empty `output` vector.
835 /// 2. Iterate over all candidates and their fair weights.
836 /// 3. Sum the `Backed` values for each candidate to get `total`.
837 /// 4. Include candidate in `output` if `total >= ctx.threshold`.
838 /// 5. Return an iterator over the filtered candidates.
839 compute: |input, ctx| {
840 // Step 1: Prepare output vector
841 let mut output = Vec::new();
842
843 // Step 2: Iterate over candidates
844 for (candidate, fairweight) in input.clone() {
845 // Step 3: Compute total backing
846 let total: Backed = fairweight
847 .into_iter()
848 .map(|(_, b)| b)
849 .fold(Backed::zero(), |acc, x| acc + x);
850
851 // Step 4: Include only candidates meeting threshold
852 if total >= ctx.threshold {
853 output.push(candidate);
854 }
855 }
856
857 // Step 5: Return as iterator
858 output.into_iter().collect()
859 }
860 }
861}
862
863// ===============================================================================
864// ```````````````````````` ELECTION MODELS PLUGIN TESTS `````````````````````````
865// ===============================================================================
866
867#[cfg(test)]
868mod tests {
869
870 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
871 // ``````````````````````````````````` IMPORTS ```````````````````````````````````
872 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
873
874 // --- Local crate imports ---
875 use crate::elections::{
876 fair,
877 flat::{self},
878 };
879
880 // --- FRAME Suite ---
881 use frame_suite::plugin_test;
882
883 // --- Substrate primitives ---
884 use sp_runtime::AccountId32;
885
886 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
887 // `````````````````````````````````` CONSTANTS ``````````````````````````````````
888 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
889
890 const fn account_frm_seed(seed: u8) -> AccountId32 {
891 let mut data = [0u8; 32];
892 data[31] = seed;
893 AccountId32::new(data)
894 }
895
896 const ALICE: AccountId32 = account_frm_seed(1);
897 const BOB: AccountId32 = account_frm_seed(2);
898 const CHARLIE: AccountId32 = account_frm_seed(3);
899 const ALAN: AccountId32 = account_frm_seed(4);
900 const MIKE: AccountId32 = account_frm_seed(5);
901 const CAROL: AccountId32 = account_frm_seed(6);
902 const DAVE: AccountId32 = account_frm_seed(7);
903 const FRANK: AccountId32 = account_frm_seed(8);
904 const GRACE: AccountId32 = account_frm_seed(9);
905 const IVAN: AccountId32 = account_frm_seed(10);
906 const EVE: AccountId32 = account_frm_seed(11);
907 const WADE: AccountId32 = account_frm_seed(12);
908 const NIX: AccountId32 = account_frm_seed(13);
909 const LAYA: AccountId32 = account_frm_seed(14);
910
911 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
912 // ````````````````````````````` FLAT-ELECTION MODELS ````````````````````````````
913 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
914
915 // ----------------------------------- TOP-DOWN ----------------------------------
916
917 plugin_test! {
918 model: flat::TopDownFlatModel,
919 input: Vec<(AccountId32, u64)>,
920 output: Vec<AccountId32>,
921 cases : {
922 (top_down_model_basic_flat_election,
923 vec![(ALICE, 180), (BOB, 170), (CHARLIE, 200), (ALAN, 210), (MIKE, 90)],
924 vec![ALAN, CHARLIE, ALICE, BOB, MIKE]
925 ),
926 (top_down_model_equal_weights,
927 // All candidates have equal weight - order preserved from input
928 vec![(ALICE, 100), (BOB, 100), (CHARLIE, 100), (ALAN, 100)],
929 vec![ALICE, BOB, CHARLIE, ALAN]
930 ),
931 (top_down_model_multiple_ties,
932 // Multiple groups of tied weights
933 vec![(ALICE, 200), (BOB, 100), (CHARLIE, 200), (ALAN, 100), (MIKE, 300)],
934 vec![MIKE, ALICE, CHARLIE, BOB, ALAN]
935 ),
936 }
937 }
938
939 // ---------------------------------- THRESHOLD ----------------------------------
940
941 plugin_test! {
942 model: flat::ThresholdFlatModel,
943 input: Vec<(AccountId32, u64)>,
944 output: Vec<AccountId32>,
945 context: flat::ThresholdFlatModelConfig<u64>,
946 value: flat::ThresholdFlatModelConfig {
947 threshold: 200
948 },
949 cases : {
950 (threshold_model_basic_flat_election,
951 vec![(ALICE, 180), (BOB, 230), (CHARLIE, 200), (ALAN, 270), (MIKE, 90)],
952 vec![BOB, CHARLIE, ALAN]
953 ),
954 (threshold_model_exact_threshold,
955 // Candidate exactly at threshold should be included
956 vec![(ALICE, 200), (BOB, 200), (CHARLIE, 170)],
957 vec![ALICE, BOB]
958 ),
959 (threshold_model_preserves_order,
960 // Verify input order is preserved for qualifying candidates
961 vec![(MIKE, 250), (ALICE, 300), (BOB, 280), (CHARLIE, 190), (ALAN, 260)],
962 vec![MIKE, ALICE, BOB, ALAN]
963 ),
964 }
965 }
966
967 plugin_test! {
968 model: flat::ThresholdFlatModel,
969 input: Vec<(AccountId32, u64)>,
970 output: Vec<AccountId32>,
971 context: flat::ThresholdFlatModelConfig<u64>,
972 value: flat::ThresholdFlatModelConfig {
973 threshold: 1000
974 },
975 cases : {
976 (threshold_model_high_threshold,
977 // high threshold, only top candidates qualify
978 vec![(ALICE, 500), (BOB, 1200), (CHARLIE, 900), (ALAN, 1500)],
979 vec![BOB, ALAN]
980 ),
981 (threshold_model_none_qualify_high_threshold,
982 // no candidates qualify
983 vec![(ALICE, 500), (BOB, 800), (CHARLIE, 900)],
984 vec![]
985 )
986 }
987 }
988
989 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
990 // ````````````````````````````` FAIR-ELECTION MODELS ````````````````````````````
991 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
992
993 // ----------------------------------- TOP-DOWN ----------------------------------
994
995 plugin_test! {
996 model: fair::TopDownFairModel,
997 input: Vec<(AccountId32, Vec<(AccountId32, u64)>)>,
998 output: Vec<AccountId32>,
999 cases : {
1000 (top_down_model_basic_fair_election,
1001 // ALICE : 110, BOB : 170, CHARLIE: 180, ALAN: 60
1002 vec![(ALICE, vec![(CAROL, 50), (DAVE, 60)]), (BOB, vec![(FRANK, 50), (GRACE, 120)]), (CHARLIE, vec![(MIKE, 80), (IVAN, 100)]), (ALAN, vec![(EVE, 60)])],
1003 vec![CHARLIE, BOB, ALICE, ALAN]
1004 ),
1005 (top_down_model_equal_total_backing,
1006 // All candidates have equal total backing - preserves input order
1007 // ALICE: 100, BOB: 100, CHARLIE: 100
1008 vec![
1009 (ALICE, vec![(CAROL, 50), (DAVE, 50)]),
1010 (BOB, vec![(FRANK, 60), (GRACE, 40)]),
1011 (CHARLIE, vec![(MIKE, 30), (IVAN, 70)])
1012 ],
1013 vec![ALICE, BOB, CHARLIE]
1014 ),
1015 (top_down_model_no_backers,
1016 // candidates with no backers (total = 0)
1017 vec![
1018 (ALICE, vec![]),
1019 (BOB, vec![]),
1020 (CHARLIE, vec![])
1021 ],
1022 vec![ALICE, BOB, CHARLIE]
1023 ),
1024 (top_down_model_mixed_fair_election,
1025 vec![
1026 (ALICE, vec![(IVAN, 200)]), (NIX, vec![(FRANK, 300)]), (MIKE, vec![(GRACE, 200)]), (BOB, vec![]), (CHARLIE, vec![]),
1027 (DAVE, vec![]), (LAYA, vec![]), (ALAN, vec![(CAROL, 150)])
1028 ],
1029 vec![NIX, ALICE, MIKE, ALAN, BOB, CHARLIE, DAVE, LAYA]
1030 )
1031 }
1032 }
1033
1034 // ----------------------------------- BALANCED ----------------------------------
1035
1036 plugin_test! {
1037 model: fair::BalancedModel,
1038 input: Vec<(AccountId32, Vec<(AccountId32, usize)>)>,
1039 output: Vec<AccountId32>,
1040 cases : {
1041 (balanced_model_basic_fair_election,
1042 // ALICE : total=120 (3 backer), avg=40, score=140
1043 // BOB : total=170 (2 backer), avg=85, score=255
1044 // CHARLIE: total=180 (2 backer), avg=90, score=270
1045 // ALAN: total=60 (1 backer), avg=60, score=120
1046 vec![(ALICE, vec![(CAROL, 50), (DAVE, 50), (EVE, 20)]), (BOB, vec![(FRANK, 50), (GRACE, 120)]), (CHARLIE, vec![(MIKE, 80), (IVAN, 100)]), (ALAN, vec![(EVE, 60)])],
1047 vec![CHARLIE, BOB, ALICE, ALAN]
1048 ),
1049 (balanced_model_single_backer_advantage,
1050 // Single large backer vs many small backers
1051 // ALICE: total=100 (1 backer), avg=100, score=200
1052 // BOB: total=100 (5 backers), avg=20, score=120
1053 // ALICE wins due to higher average
1054 vec![
1055 (ALICE, vec![(CAROL, 100)]),
1056 (BOB, vec![(DAVE, 20), (FRANK, 20), (GRACE, 20), (MIKE, 20), (IVAN, 20)])
1057 ],
1058 vec![ALICE, BOB]
1059 ),
1060 (balanced_model_many_small_backers,
1061 // Candidate with many small backers vs few large backers
1062 // ALICE: total=500 (10 backers), avg=50, score=550
1063 // BOB: total=500 (2 backers), avg=250, score=750
1064 // BOB wins due to higher average
1065 vec![
1066 (ALICE, vec![
1067 (CAROL, 50), (DAVE, 50), (FRANK, 50), (GRACE, 50), (MIKE, 50),
1068 (IVAN, 50), (EVE, 50), (WADE, 50), (ALAN, 50), (CHARLIE, 50)
1069 ]),
1070 (BOB, vec![(CAROL, 250), (DAVE, 250)])
1071 ],
1072 vec![BOB, ALICE]
1073 ),
1074 (balanced_model_equal_combined_score,
1075 // Equal combined scores - preserves input order
1076 // ALICE: total=100, avg=50, score=150
1077 // BOB: total=120, avg=30, score=150
1078 // CHARLIE: total=90, avg=60, score=150
1079 vec![
1080 (ALICE, vec![(CAROL, 50), (DAVE, 50)]),
1081 (BOB, vec![(FRANK, 30), (GRACE, 30), (MIKE, 30), (IVAN, 30)]),
1082 (CHARLIE, vec![(EVE, 30), (WADE, 60)])
1083 ],
1084 vec![ALICE, BOB, CHARLIE]
1085 ),
1086 (balanced_model_no_backers,
1087 // Edge case: candidates with no backers
1088 // score = 0 + 0 = 0 for all
1089 vec![
1090 (ALICE, vec![]),
1091 (BOB, vec![]),
1092 (CHARLIE, vec![])
1093 ],
1094 vec![ALICE, BOB, CHARLIE]
1095 ),
1096 }
1097 }
1098
1099 // ----------------------------------- PHRAGMEN ----------------------------------
1100
1101 plugin_test! {
1102 model: fair::PhragmenModel,
1103 input: Vec<(AccountId32, Vec<(AccountId32, usize)>)>,
1104 output: Vec<AccountId32>,
1105 context: fair::PhragmenModelConfig<usize>,
1106 value: fair::PhragmenModelConfig {
1107 weighted: false,
1108 scale: None
1109 },
1110 cases: {
1111 (phragmen_sequential_true_fair_election,
1112 // ALICE: 110 (CAROL:50, DAVE:60) - max_load=60
1113 // BOB: 170 (FRANK:50, GRACE:120) - max_load=120
1114 // CHARLIE: 180 (MIKE:80, IVAN:100) - max_load=100
1115 // ALAN: 60 (EVE:60) - max_load=60
1116 // Round 1: ALICE wins (60, first encountered)
1117 // Round 2: ALAN wins (60, loads updated: CAROL=50, DAVE=60)
1118 // Round 3: CHARLIE wins (100 < 120)
1119 // Round 4: BOB remains
1120 vec![
1121 (ALICE, vec![(CAROL, 50), (DAVE, 60)]),
1122 (BOB, vec![(FRANK, 50), (GRACE, 120)]),
1123 (CHARLIE, vec![(MIKE, 80), (IVAN, 100)]),
1124 (ALAN, vec![(EVE, 60)])
1125 ],
1126 vec![ALICE, ALAN, CHARLIE, BOB]
1127 ),
1128 (phragmen_whale_monopoly_prevention,
1129 // Real-world: Company board election with 3 seats
1130 // BigCorp (10,000 shares) tries to control all 3 seats
1131 // Small shareholders (100 shares each) band together for 1 candidate
1132 //
1133 // Candidates:
1134 // - ALICE, BOB, CHARLIE: backed by BigCorp (10,000 each)
1135 // - DAVE: backed by 10 small shareholders (100 each, total 1,000)
1136 //
1137 // TopDown would give: [ALICE, BOB, CHARLIE] - BigCorp controls 100%
1138 // Phragmen gives: [DAVE, ALICE, BOB] - Small shareholders get 33% representation
1139 //
1140 // Round 1: DAVE wins (max_load = 100 vs 10,000)
1141 // Round 2: ALICE wins (BigCorp load still 0, max_load = 10,000)
1142 // Round 3: BOB wins (BigCorp now at 10,000, max_load = 20,000)
1143 vec![
1144 (ALICE, vec![(WADE, 10000)]),
1145 (BOB, vec![(WADE, 10000)]),
1146 (CHARLIE, vec![(WADE, 10000)]),
1147 (DAVE, vec![
1148 (CAROL, 100), (DAVE, 100), (FRANK, 100), (GRACE, 100),
1149 (IVAN, 100), (EVE, 100), (MIKE, 100), (ALAN, 100)
1150 ])
1151 ],
1152 vec![DAVE, ALICE, BOB, CHARLIE]
1153 ),
1154 (phragmen_single_backer_multiple_candidates,
1155 // CAROL backs all three candidates
1156 // ALICE: CAROL:100 - max_load=100
1157 // BOB: CAROL:50 - max_load=50
1158 // CHARLIE: CAROL:75 - max_load=75
1159 // Round 1: BOB wins (50)
1160 // Round 2: CHARLIE wins (50+75=125 vs 50+100=150)
1161 // Round 3: ALICE (150)
1162 vec![
1163 (ALICE, vec![(CAROL, 100)]),
1164 (BOB, vec![(CAROL, 50)]),
1165 (CHARLIE, vec![(CAROL, 75)])
1166 ],
1167 vec![BOB, CHARLIE, ALICE]
1168 ),
1169 (phragmen_equal_contributions_fair_election,
1170 // All candidates have same total backing and max_load
1171 // Tie-breaking by input order
1172 vec![
1173 (ALICE, vec![(CAROL, 50), (DAVE, 50)]),
1174 (BOB, vec![(FRANK, 50), (GRACE, 50)]),
1175 (CHARLIE, vec![(MIKE, 50), (IVAN, 50)])
1176 ],
1177 vec![ALICE, BOB, CHARLIE]
1178 ),
1179 (phragmen_no_backers_fair_election,
1180 // All candidates have zero backing
1181 vec![
1182 (ALICE, vec![]),
1183 (BOB, vec![]),
1184 (CHARLIE, vec![])
1185 ],
1186 vec![ALICE, BOB, CHARLIE]
1187 ),
1188 (phragmen_overlapping_backers_fair_election,
1189 // CAROL backs ALICE(60) and BOB(40)
1190 // DAVE backs only ALICE(80)
1191 // ALICE: max(CAROL:60, DAVE:80) = 80
1192 // BOB: CAROL:40 = 40
1193 // Round 1: BOB wins (40)
1194 // Round 2: ALICE (CAROL now at 40, so max(40+60=100, 0+80=80) = 100)
1195 vec![
1196 (ALICE, vec![(CAROL, 60), (DAVE, 80)]),
1197 (BOB, vec![(CAROL, 40)])
1198 ],
1199 vec![BOB, ALICE]
1200 ),
1201 }
1202 }
1203
1204 plugin_test! {
1205 model: fair::PhragmenModel,
1206 input: Vec<(AccountId32, Vec<(AccountId32, usize)>)>,
1207 output: Vec<AccountId32>,
1208 context: fair::PhragmenModelConfig<usize>,
1209 value: fair::PhragmenModelConfig {
1210 weighted: true,
1211 scale: Some(2) // Double all contributions
1212 },
1213 cases: {
1214 (phragmen_weighted_scaled_fair_election,
1215 // All contributions doubled:
1216 // ALICE: CAROL:100, DAVE:120 - max_load=120
1217 // BOB: FRANK:100, GRACE:240 - max_load=240
1218 // CHARLIE: MIKE:160, IVAN:200 - max_load=200
1219 // ALAN: EVE:120 - max_load=120
1220 // Round 1: ALICE wins (120, first encountered)
1221 // Round 2: ALAN wins (120, loads: CAROL=100, DAVE=120)
1222 // Round 3: CHARLIE wins (200 < 240)
1223 // Round 4: BOB
1224 vec![
1225 (ALICE, vec![(CAROL, 50), (DAVE, 60)]),
1226 (BOB, vec![(FRANK, 50), (GRACE, 120)]),
1227 (CHARLIE, vec![(MIKE, 80), (IVAN, 100)]),
1228 (ALAN, vec![(EVE, 60)])
1229 ],
1230 vec![ALICE, ALAN, CHARLIE, BOB]
1231 )
1232 }
1233 }
1234 plugin_test! {
1235 model: fair::PhragmenModel,
1236 input: Vec<(AccountId32, Vec<(AccountId32, usize)>)>,
1237 output: Vec<AccountId32>,
1238 context: fair::PhragmenModelConfig<usize>,
1239 value: fair::PhragmenModelConfig {
1240 weighted: true,
1241 scale: Some(10) // 10x multiplier
1242 },
1243 cases: {
1244 (phragmen_large_scale_fair_election,
1245 // All contributions multiplied by 10
1246 // ALICE: max(CAROL:500, DAVE:600) = 600
1247 // BOB: max(FRANK:500, GRACE:1200) = 1200
1248 // CHARLIE: max(MIKE:800, IVAN:1000) = 1000
1249 vec![
1250 (ALICE, vec![(CAROL, 50), (DAVE, 60)]),
1251 (BOB, vec![(FRANK, 50), (GRACE, 120)]),
1252 (CHARLIE, vec![(MIKE, 80), (IVAN, 100)])
1253 ],
1254 vec![ALICE, CHARLIE, BOB]
1255 )
1256 }
1257 }
1258
1259 // --------------------------------- MAX-MIN LOAD --------------------------------
1260
1261 plugin_test! {
1262 model: fair::MaxMinLoadModel,
1263 input: Vec<(AccountId32, Vec<(AccountId32, u64)>)>,
1264 output: Vec<AccountId32>,
1265 cases: {
1266 (max_min_load_basic,
1267 // ALICE: max_load=60, BOB: max_load=120, CHARLIE: max_load=100, ALAN: max_load=60
1268 vec![
1269 (ALICE, vec![(CAROL, 50), (DAVE, 60)]),
1270 (BOB, vec![(FRANK, 50), (GRACE, 120)]),
1271 (CHARLIE, vec![(MIKE, 80), (IVAN, 100)]),
1272 (ALAN, vec![(EVE, 60)])
1273 ],
1274 vec![ALICE, ALAN, CHARLIE, BOB]
1275 ),
1276 (max_min_load_single_backer,
1277 // CAROL backs all - load accumulates
1278 vec![
1279 (ALICE, vec![(CAROL, 100)]),
1280 (BOB, vec![(CAROL, 50)]),
1281 (CHARLIE, vec![(CAROL, 75)])
1282 ],
1283 vec![BOB, CHARLIE, ALICE]
1284 ),
1285 (max_min_load_overlapping_backers,
1286 // Demonstrates load balancing across shared backers
1287 vec![
1288 (ALICE, vec![(CAROL, 60), (DAVE, 80)]),
1289 (BOB, vec![(CAROL, 40)])
1290 ],
1291 vec![BOB, ALICE]
1292 ),
1293 (max_min_load_no_backers,
1294 vec![
1295 (ALICE, vec![]),
1296 (BOB, vec![]),
1297 (CHARLIE, vec![])
1298 ],
1299 vec![ALICE, BOB, CHARLIE]
1300 )
1301 }
1302 }
1303
1304 // ---------------------------------- THRESHOLD ----------------------------------
1305
1306 plugin_test! {
1307 model: fair::ThresholdFairModel,
1308 input: Vec<(AccountId32, Vec<(AccountId32, u64)>)>,
1309 output: Vec<AccountId32>,
1310 context: fair::ThresholdFairModelConfig<u64>,
1311 value: fair::ThresholdFairModelConfig {
1312 threshold: 100
1313 },
1314 cases: {
1315 (threshold_model_basic_fair_election,
1316 // Basic case: filter candidates by total backing >= threshold
1317 // ALICE: 80 (below), BOB: 110 (above), CHARLIE: 130 (above), ALAN: 40 (below)
1318 vec![
1319 (ALICE, vec![(CAROL, 50), (DAVE, 30)]),
1320 (BOB, vec![(FRANK, 50), (GRACE, 60)]),
1321 (CHARLIE, vec![(MIKE, 80), (IVAN, 50)]),
1322 (ALAN, vec![(EVE, 40)])
1323 ],
1324 vec![BOB, CHARLIE]
1325 ),
1326 (threshold_model_all_qualify,
1327 // All candidates meet threshold
1328 // ALICE: 150, BOB: 200, CHARLIE: 180
1329 vec![
1330 (ALICE, vec![(CAROL, 80), (DAVE, 70)]),
1331 (BOB, vec![(FRANK, 100), (GRACE, 100)]),
1332 (CHARLIE, vec![(MIKE, 90), (IVAN, 90)])
1333 ],
1334 vec![ALICE, BOB, CHARLIE]
1335 ),
1336 (threshold_model_none_qualify,
1337 // No candidates meet threshold
1338 // ALICE: 90, BOB: 80, CHARLIE: 70
1339 vec![
1340 (ALICE, vec![(CAROL, 50), (DAVE, 40)]),
1341 (BOB, vec![(FRANK, 40), (GRACE, 40)]),
1342 (CHARLIE, vec![(MIKE, 30), (IVAN, 40)])
1343 ],
1344 vec![]
1345 ),
1346 (threshold_model_no_backers,
1347 // Edge case: candidates with no backers (total = 0)
1348 vec![
1349 (ALICE, vec![]),
1350 (BOB, vec![]),
1351 (CHARLIE, vec![])
1352 ],
1353 vec![]
1354 ),
1355 }
1356 }
1357
1358 plugin_test! {
1359 model: fair::ThresholdFairModel,
1360 input: Vec<(AccountId32, Vec<(AccountId32, u64)>)>,
1361 output: Vec<AccountId32>,
1362 context: fair::ThresholdFairModelConfig<u64>,
1363 value: fair::ThresholdFairModelConfig {
1364 threshold: 0
1365 },
1366 cases: {
1367 (threshold_model_zero_threshold_all_qualify,
1368 // Zero threshold - all candidates qualify, even with zero backing
1369 vec![
1370 (ALICE, vec![(CAROL, 50), (DAVE, 30)]),
1371 (BOB, vec![]),
1372 (CHARLIE, vec![(MIKE, 80)]),
1373 (ALAN, vec![])
1374 ],
1375 vec![ALICE, BOB, CHARLIE, ALAN]
1376 ),
1377
1378 (threshold_model_zero_threshold_preserves_order,
1379 // Verify all candidates preserved in input order
1380 vec![
1381 (CHARLIE, vec![(MIKE, 100)]),
1382 (ALICE, vec![(CAROL, 50)]),
1383 (BOB, vec![(DAVE, 75)]),
1384 (ALAN, vec![])
1385 ],
1386 vec![CHARLIE, ALICE, BOB, ALAN]
1387 ),
1388 }
1389 }
1390}