pp-phragmen 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. pp_phragmen-0.1.0/PKG-INFO +9 -0
  2. pp_phragmen-0.1.0/README.md +3 -0
  3. pp_phragmen-0.1.0/pp_phragmen.egg-info/PKG-INFO +9 -0
  4. pp_phragmen-0.1.0/pp_phragmen.egg-info/SOURCES.txt +28 -0
  5. pp_phragmen-0.1.0/pp_phragmen.egg-info/dependency_links.txt +1 -0
  6. pp_phragmen-0.1.0/pp_phragmen.egg-info/requires.txt +1 -0
  7. pp_phragmen-0.1.0/pp_phragmen.egg-info/top_level.txt +1 -0
  8. pp_phragmen-0.1.0/protocol/__init__.py +1 -0
  9. pp_phragmen-0.1.0/protocol/algorithm1_permutation.py +147 -0
  10. pp_phragmen-0.1.0/protocol/algorithm2_validation.py +95 -0
  11. pp_phragmen-0.1.0/protocol/algorithm3_initialization.py +56 -0
  12. pp_phragmen-0.1.0/protocol/algorithm4_score.py +116 -0
  13. pp_phragmen-0.1.0/protocol/algorithm5_find_min.py +109 -0
  14. pp_phragmen-0.1.0/protocol/algorithm6_one_hot.py +135 -0
  15. pp_phragmen-0.1.0/protocol/algorithm7_wallet_update.py +149 -0
  16. pp_phragmen-0.1.0/protocol/algorithm8_reconstruct_winner.py +79 -0
  17. pp_phragmen-0.1.0/protocol/state_manager.py +139 -0
  18. pp_phragmen-0.1.0/protocol/types.py +16 -0
  19. pp_phragmen-0.1.0/pyproject.toml +18 -0
  20. pp_phragmen-0.1.0/setup.cfg +4 -0
  21. pp_phragmen-0.1.0/tests/test_algorithm1.py +221 -0
  22. pp_phragmen-0.1.0/tests/test_algorithm2.py +209 -0
  23. pp_phragmen-0.1.0/tests/test_algorithm3.py +91 -0
  24. pp_phragmen-0.1.0/tests/test_algorithm4.py +291 -0
  25. pp_phragmen-0.1.0/tests/test_algorithm5.py +173 -0
  26. pp_phragmen-0.1.0/tests/test_algorithm6.py +186 -0
  27. pp_phragmen-0.1.0/tests/test_algorithm7.py +552 -0
  28. pp_phragmen-0.1.0/tests/test_algorithm8.py +138 -0
  29. pp_phragmen-0.1.0/tests/test_field_size.py +236 -0
  30. pp_phragmen-0.1.0/tests/test_integration.py +337 -0
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: pp-phragmen
3
+ Version: 0.1.0
4
+ Summary: Privacy-Preserving Phragmén Voting via Secure Multi-Party Computation
5
+ Author-email: Liran Shem-Tov <liranst13@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/liranst13/Phragmen_Voting
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: mpc-secret-shares>=0.2.0
@@ -0,0 +1,3 @@
1
+ # PP-Phragmén: Privacy-Preserving Phragmén Voting
2
+
3
+ PP-Phragmén is a cryptographic implementation of the Phragmén proportional representation voting method in which all ballots and intermediate tallies remain secret throughout the computation. The protocol runs as a Secure Multi-Party Computation (MPC) over Shamir secret shares: each party holds only an encrypted fragment of every vote and score, and the winning committee is determined by jointly executing a sequence of secure comparison, multiplication, and division sub-protocols without any party ever learning another party's inputs or any intermediate plaintext. This implementation follows Algorithms 1–8 of the accompanying Artificial Intelligence journal submission and is built on top of the MPC primitive library in `mpc_primitives/`.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: pp-phragmen
3
+ Version: 0.1.0
4
+ Summary: Privacy-Preserving Phragmén Voting via Secure Multi-Party Computation
5
+ Author-email: Liran Shem-Tov <liranst13@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/liranst13/Phragmen_Voting
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: mpc-secret-shares>=0.2.0
@@ -0,0 +1,28 @@
1
+ README.md
2
+ pyproject.toml
3
+ pp_phragmen.egg-info/PKG-INFO
4
+ pp_phragmen.egg-info/SOURCES.txt
5
+ pp_phragmen.egg-info/dependency_links.txt
6
+ pp_phragmen.egg-info/requires.txt
7
+ pp_phragmen.egg-info/top_level.txt
8
+ protocol/__init__.py
9
+ protocol/algorithm1_permutation.py
10
+ protocol/algorithm2_validation.py
11
+ protocol/algorithm3_initialization.py
12
+ protocol/algorithm4_score.py
13
+ protocol/algorithm5_find_min.py
14
+ protocol/algorithm6_one_hot.py
15
+ protocol/algorithm7_wallet_update.py
16
+ protocol/algorithm8_reconstruct_winner.py
17
+ protocol/state_manager.py
18
+ protocol/types.py
19
+ tests/test_algorithm1.py
20
+ tests/test_algorithm2.py
21
+ tests/test_algorithm3.py
22
+ tests/test_algorithm4.py
23
+ tests/test_algorithm5.py
24
+ tests/test_algorithm6.py
25
+ tests/test_algorithm7.py
26
+ tests/test_algorithm8.py
27
+ tests/test_field_size.py
28
+ tests/test_integration.py
@@ -0,0 +1 @@
1
+ mpc-secret-shares>=0.2.0
@@ -0,0 +1 @@
1
+ protocol
@@ -0,0 +1 @@
1
+ # PP-Phragmén voting protocol — Algorithms 1–8.
@@ -0,0 +1,147 @@
1
+ """Algorithm 1 — Oblivious Candidate Permutation.
2
+
3
+ Reference: Algorithm 1 in "Fairness Without Exposure: Privacy-Preserving
4
+ Phragmén Voting", Section 5.1 (page 8). Run once at setup, before any
5
+ election period. No MPC secure operations are required.
6
+
7
+ Each tallying party P_d (d ∈ [D]):
8
+ 1. Samples a secret random seed s_d ← {0,1}^λ.
9
+ 2. Broadcasts a commitment com_d = SHA-256(s_d ‖ d).
10
+ 3. Reveals s_d; all other parties verify the commitment.
11
+
12
+ The shared seed is s = s_1 ⊕ … ⊕ s_D, and the public permutation is
13
+ derived by running a Fisher-Yates shuffle seeded with s.
14
+ """
15
+
16
+ import hashlib
17
+ import os
18
+ import random
19
+ from typing import List, Optional
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Internal helpers (exposed for unit-testing)
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def _commitment(seed: bytes, party_index: int) -> bytes:
27
+ """Return SHA-256(seed ‖ party_index) as defined in Algorithm 1, Line 3.
28
+
29
+ Args:
30
+ seed: The party's random seed bytes.
31
+ party_index: Zero-based party index, encoded as a big-endian 4-byte
32
+ unsigned integer before hashing.
33
+
34
+ Returns:
35
+ 32-byte SHA-256 digest.
36
+ """
37
+ return hashlib.sha256(seed + party_index.to_bytes(4, "big")).digest()
38
+
39
+
40
+ def _verify_commitments(seeds: List[bytes], commitments: List[bytes]) -> None:
41
+ """Verify every revealed seed against its published commitment.
42
+
43
+ Implements the verification step in Algorithm 1, Lines 6–8.
44
+
45
+ Args:
46
+ seeds: Revealed seeds, one per party (zero-indexed).
47
+ commitments: Published commitments in the same order.
48
+
49
+ Raises:
50
+ ValueError: If any seed does not reproduce its commitment.
51
+ """
52
+ for d, (seed, com) in enumerate(zip(seeds, commitments)):
53
+ if _commitment(seed, d) != com:
54
+ raise ValueError(
55
+ f"commitment mismatch for party {d}: "
56
+ "revealed seed does not match published commitment"
57
+ )
58
+
59
+
60
+ def _fisher_yates(combined_seed: bytes, num_candidates: int) -> List[int]:
61
+ """Produce a uniformly random permutation of [0, M) via Fisher-Yates.
62
+
63
+ Implements Algorithm 1, Line 10. The combined seed is converted to an
64
+ integer and used to seed Python's Mersenne-Twister PRNG, which then
65
+ drives `random.shuffle` (a Fisher-Yates implementation).
66
+
67
+ Args:
68
+ combined_seed: XOR of all party seeds (Algorithm 1, Line 9).
69
+ num_candidates: M — size of the candidate set.
70
+
71
+ Returns:
72
+ A permutation of [0, M) as a list of length M.
73
+ """
74
+ seed_int = int.from_bytes(combined_seed, "big")
75
+ rng = random.Random(seed_int)
76
+ pi = list(range(num_candidates))
77
+ rng.shuffle(pi)
78
+ return pi
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Algorithm 1
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def algorithm_1_oblivious_candidate_permutation(
86
+ security_bits: int,
87
+ num_candidates: int,
88
+ num_parties: int,
89
+ seeds: Optional[List[bytes]] = None,
90
+ ) -> List[int]:
91
+ """Generate a public uniformly random permutation of candidates.
92
+
93
+ Algorithm 1 of "Fairness Without Exposure: Privacy-Preserving Phragmén
94
+ Voting", Section 5.1.
95
+
96
+ Each party commits to a random seed via SHA-256, then reveals it. The
97
+ combined (XOR) seed drives a Fisher-Yates shuffle that produces the
98
+ oblivious permutation π used by Algorithm 6 for tie-breaking.
99
+
100
+ Args:
101
+ security_bits: λ — bit-length of each party's random seed.
102
+ Must be a positive multiple of 8.
103
+ num_candidates: M — number of candidates to permute.
104
+ num_parties: D (= n in MPC notation) — number of tallying parties.
105
+ seeds: Optional list of D byte-strings each of length
106
+ ``security_bits // 8``. When None every party samples a fresh
107
+ random seed. Supply explicit seeds only in tests to obtain
108
+ deterministic output.
109
+
110
+ Returns:
111
+ A permutation π of [0, M) as a list of M distinct integers.
112
+
113
+ Raises:
114
+ ValueError: If ``seeds`` has wrong length or wrong element sizes.
115
+ ValueError: If any revealed seed does not match its commitment
116
+ (simulates an abort on cheating during the reveal phase).
117
+ """
118
+ seed_len = security_bits // 8
119
+
120
+ # Lines 1-3: every party samples a seed and publishes a commitment.
121
+ if seeds is None:
122
+ seeds = [os.urandom(seed_len) for _ in range(num_parties)]
123
+ else:
124
+ if len(seeds) != num_parties:
125
+ raise ValueError(
126
+ f"expected {num_parties} seeds, got {len(seeds)}"
127
+ )
128
+ for d, s in enumerate(seeds):
129
+ if len(s) != seed_len:
130
+ raise ValueError(
131
+ f"seed for party {d} has length {len(s)}, "
132
+ f"expected {seed_len} (security_bits={security_bits})"
133
+ )
134
+
135
+ commitments = [_commitment(s, d) for d, s in enumerate(seeds)] # Line 3
136
+
137
+ # Lines 4-8: reveal phase — every party broadcasts its seed and all
138
+ # others verify it against the stored commitment.
139
+ _verify_commitments(seeds, commitments) # Lines 6-8
140
+
141
+ # Line 9: derive the shared seed by XOR-ing all party seeds.
142
+ combined = seeds[0]
143
+ for s in seeds[1:]:
144
+ combined = bytes(a ^ b for a, b in zip(combined, s))
145
+
146
+ # Lines 10-11: Fisher-Yates shuffle seeded with the combined seed.
147
+ return _fisher_yates(combined, num_candidates)
@@ -0,0 +1,95 @@
1
+ """Algorithm 2 — Input Validation, Sanitization & Compaction.
2
+
3
+ Reference: Algorithm 2 in "Fairness Without Exposure: Privacy-Preserving
4
+ Phragmén Voting", Section 5.2 (page 9). Run once at setup (or per period
5
+ if ballots are re-submitted).
6
+
7
+ For each ballot entry B_{n,m} the protocol verifies B_{n,m} ∈ {0, 1} by
8
+ checking that B_{n,m}·(B_{n,m} − 1) = 0 using a single SecureMult followed
9
+ by a public Reconstruct. Voters whose ballot fails this check are flagged as
10
+ cheaters and removed; the remaining honest-voter rows are compacted into a
11
+ fresh matrix B̂ of size N_valid × M.
12
+ """
13
+
14
+ from typing import List, Tuple
15
+
16
+ from mpc_secret_shares import (
17
+ reconstruct,
18
+ secure_mult,
19
+ shares_one,
20
+ shares_sub,
21
+ )
22
+ from protocol.types import BallotMatrix, Shares
23
+
24
+
25
+ def algorithm_2_input_validation(
26
+ B_shares: BallotMatrix,
27
+ n: int,
28
+ t: int,
29
+ p: int,
30
+ ) -> Tuple[BallotMatrix, int]:
31
+ """Validate ballot entries, remove cheating voters, and compact the matrix.
32
+
33
+ Algorithm 2 of "Fairness Without Exposure: Privacy-Preserving Phragmén
34
+ Voting", Section 5.2.
35
+
36
+ For each entry B_{n,m} the protocol computes
37
+ ``[[u]] = SecureMult([[B]], [[B]] − [[1]])``
38
+ and reconstructs u publicly. Because u = B·(B−1) equals 0 if and only
39
+ if B ∈ {0, 1}, a non-zero u identifies voter n as a cheater. The first
40
+ invalid entry short-circuits the inner loop (Lines 7–8 of the paper).
41
+
42
+ Args:
43
+ B_shares: An N × M matrix of (t,n)-sharings, where
44
+ ``B_shares[voter][candidate]`` is the sharing of B_{n,m}.
45
+ Voters are zero-indexed; the paper uses 1-indexed notation.
46
+ n: Number of MPC parties (talliers).
47
+ t: Reconstruction threshold.
48
+ p: Prime field modulus.
49
+
50
+ Returns:
51
+ A tuple ``(B_hat, n_valid)`` where:
52
+
53
+ * ``B_hat`` is an N_valid × M matrix of (t,n)-sharings containing
54
+ only the rows of honest voters, in their original relative order
55
+ and re-indexed from 0.
56
+ * ``n_valid`` is the count of honest voters (public after this step).
57
+ """
58
+ num_voters = len(B_shares)
59
+ num_candidates = len(B_shares[0]) if num_voters > 0 else 0
60
+
61
+ cheaters: set[int] = set()
62
+ one_shares: Shares = shares_one(n) # [[1]] — shared public constant
63
+
64
+ # Lines 2-8: for each voter, check every ballot entry until a violation
65
+ # is found. The inner loop breaks on the first detected cheat (Line 8).
66
+ for voter in range(num_voters): # Line 2
67
+ for cand in range(num_candidates): # Line 3
68
+ b_nm: Shares = B_shares[voter][cand]
69
+
70
+ # Line 4: [[u]] = SecureMult([[B]], [[B]] − [[1]])
71
+ # [[B]] − [[1]] is a local operation (affine subtraction).
72
+ b_minus_one: Shares = shares_sub(b_nm, one_shares, p)
73
+ u_shares: Shares = secure_mult(
74
+ b_nm, b_minus_one, n, t, p
75
+ )
76
+
77
+ # Line 5: u ← Reconstruct([[u]]) — publicly revealed.
78
+ u_val: int = reconstruct(u_shares[:t], p)
79
+
80
+ # Lines 6-8: non-zero u means B ∉ {0, 1} → voter is a cheater.
81
+ if u_val != 0: # Line 6
82
+ cheaters.add(voter) # Line 7
83
+ break # Line 8
84
+
85
+ # Line 9: N_valid = N − |Cheaters|.
86
+ n_valid: int = num_voters - len(cheaters)
87
+
88
+ # Lines 10-15: compact honest voters into B_hat, preserving row order.
89
+ B_hat: BallotMatrix = [
90
+ B_shares[voter]
91
+ for voter in range(num_voters)
92
+ if voter not in cheaters
93
+ ]
94
+
95
+ return B_hat, n_valid # Line 16
@@ -0,0 +1,56 @@
1
+ """Algorithm 3 — Initialization.
2
+
3
+ Reference: Algorithm 3 in "Fairness Without Exposure: Privacy-Preserving
4
+ Phragmén Voting", Section 5.3 (page 9). Run once, prior to the first
5
+ election period only.
6
+
7
+ Sets the global denominator Δ to 1 and every voter's scaled wallet W̃_i to 0,
8
+ establishing the invariant w_n = W̃_n / Δ = 0 for all voters at the start.
9
+ No MPC secure operations are required — both values are public constants.
10
+ """
11
+
12
+ from typing import List, Tuple
13
+
14
+ from mpc_secret_shares import (
15
+ shares_one,
16
+ shares_zero,
17
+ )
18
+ from protocol.types import Shares
19
+
20
+
21
+ def algorithm_3_initialization(
22
+ n_valid: int,
23
+ n: int,
24
+ t: int,
25
+ p: int,
26
+ ) -> Tuple[Shares, List[Shares]]:
27
+ """Initialise the global denominator and all voter wallets to their
28
+ starting values.
29
+
30
+ Algorithm 3 of "Fairness Without Exposure: Privacy-Preserving Phragmén
31
+ Voting", Section 5.3.
32
+
33
+ At the start of the first period all rational wallet balances w_n are 0.
34
+ The protocol represents each balance as w_n = W̃_n / Δ, so initialising
35
+ Δ = 1 and W̃_n = 0 satisfies the invariant without any field division.
36
+
37
+ Args:
38
+ n_valid: N_valid — number of honest voters after Algorithm 2.
39
+ n: Number of MPC parties (talliers).
40
+ t: Reconstruction threshold.
41
+ p: Prime field modulus.
42
+
43
+ Returns:
44
+ A tuple ``(delta_shares, wallet_shares)`` where:
45
+
46
+ * ``delta_shares`` is a (t,n)-sharing of Δ = 1 (Algorithm 3, Line 1).
47
+ * ``wallet_shares`` is a list of N_valid (t,n)-sharings, each
48
+ encoding W̃_i = 0 (Algorithm 3, Lines 2–3).
49
+ """
50
+ # Line 1: [[Δ]] ← [[1]]
51
+ delta_shares: Shares = shares_one(n)
52
+
53
+ # Lines 2-3: [[W̃_i]] ← [[0]] for every honest voter i.
54
+ wallet_shares: List[Shares] = [shares_zero(n) for _ in range(n_valid)]
55
+
56
+ return delta_shares, wallet_shares
@@ -0,0 +1,116 @@
1
+ """Algorithm 4 — Oblivious Score Computation (Global Denominator).
2
+
3
+ Reference: Algorithm 4 in "Fairness Without Exposure: Privacy-Preserving
4
+ Phragmén Voting", Section 5.4 (pages 10–11). Run every election period.
5
+
6
+ Computes per-candidate scores in numerator/denominator form using the
7
+ common-denominator strategy (Section 5, page 7):
8
+
9
+ Score_m = Score_m^num / Score_m^den
10
+ = (Δ − T̃_m) / (|A(m)| · Δ)
11
+
12
+ where T̃_m = Σ_{i ∈ A(m)} W̃_i is the sum of scaled wallets of candidate
13
+ m's supporters, and Δ is the shared global denominator. This eliminates
14
+ per-voter cross-multiplication from the critical path (O(MN) SecureMult
15
+ instead of O(MN²)).
16
+
17
+ Precondition (paper Section 5, page 8): at least one candidate must have
18
+ |A(m)| > 0 (i.e. at least one voter approves at least one candidate).
19
+ The degenerate all-zero ballot matrix is explicitly excluded by the paper;
20
+ calling this function with n_valid=0 or all-zero ballots is outside the
21
+ paper's guaranteed domain and will produce Score^den = 0 for every candidate,
22
+ making Algorithm 5's comparison meaningless.
23
+ """
24
+
25
+ from typing import List, Tuple
26
+
27
+ from mpc_secret_shares import (
28
+ secure_mult,
29
+ shares_add,
30
+ shares_sub,
31
+ shares_zero,
32
+ )
33
+ from protocol.types import BallotMatrix, ScorePair, Shares
34
+
35
+
36
+ def algorithm_4_score_computation(
37
+ B_hat: BallotMatrix,
38
+ wallet_shares: List[Shares],
39
+ delta_shares: Shares,
40
+ n_valid: int,
41
+ num_candidates: int,
42
+ n: int,
43
+ t: int,
44
+ p: int,
45
+ ) -> Tuple[List[ScorePair], List[Shares], List[Shares]]:
46
+ """Compute per-candidate scores and supporting data for the current period.
47
+
48
+ Algorithm 4 of "Fairness Without Exposure: Privacy-Preserving Phragmén
49
+ Voting", Section 5.4.
50
+
51
+ For each candidate m the algorithm accumulates:
52
+
53
+ * ``[[|A(m)|]]`` — supporter count (local additions of ballot bits).
54
+ * ``[[T̃_m]]`` — scaled wallet sum of supporters (SecureMult per voter).
55
+
56
+ Then it derives the score representation from Equation (4) of the paper:
57
+
58
+ * ``[[Score_m^num]] = [[Δ]] − [[T̃_m]]`` (local subtraction)
59
+ * ``[[Score_m^den]] = SecureMult([[Δ]], [[|A(m)|]])``
60
+
61
+ Args:
62
+ B_hat: N_valid × M matrix of (t,n)-sharings of B̂_{i,m} ∈ {0,1}.
63
+ wallet_shares: List of N_valid (t,n)-sharings of the scaled wallets
64
+ W̃_i. Index matches the row index of B_hat.
65
+ delta_shares: (t,n)-sharing of the global denominator Δ.
66
+ n_valid: Number of valid voters N_valid. Must equal len(B_hat).
67
+ num_candidates: Number of candidates M.
68
+ n: Number of MPC parties.
69
+ t: Reconstruction threshold.
70
+ p: Prime field modulus.
71
+
72
+ Returns:
73
+ A tuple ``(scores, A_shares, T_tilde_shares)`` where:
74
+
75
+ * ``scores[m]`` is ``(Score_m^num, Score_m^den)`` — a ScorePair of
76
+ (t,n)-sharings for candidate m (Algorithm 4, Lines 8–9).
77
+ * ``A_shares[m]`` is ``[[|A(m)|]]`` — supporter count for candidate m
78
+ (Line 5), needed by Algorithm 7.
79
+ * ``T_tilde_shares[m]`` is ``[[T̃_m]]`` — scaled wallet sum for
80
+ candidate m (Line 7), needed by Algorithm 7.
81
+ """
82
+ scores: List[ScorePair] = []
83
+ A_shares: List[Shares] = []
84
+ T_tilde_shares: List[Shares] = []
85
+
86
+ for m in range(num_candidates): # Line 1
87
+ A_m: Shares = shares_zero(n) # Line 2
88
+ T_m: Shares = shares_zero(n) # Line 3
89
+
90
+ for i in range(n_valid): # Line 4
91
+ b_im: Shares = B_hat[i][m]
92
+
93
+ # Line 5: [[|A(m)|]] ← [[|A(m)|]] + [[B̂_{i,m}]] (local)
94
+ A_m = shares_add(A_m, b_im, p)
95
+
96
+ # Line 6: [[contrib]] ← SecureMult([[B̂_{i,m}]], [[W̃_i]])
97
+ contrib: Shares = secure_mult(
98
+ b_im, wallet_shares[i], n, t, p
99
+ )
100
+
101
+ # Line 7: [[T̃_m]] ← [[T̃_m]] + [[contrib]] (local)
102
+ T_m = shares_add(T_m, contrib, p)
103
+
104
+ # Line 8: [[Score_m^num]] ← [[Δ]] − [[T̃_m]] (local)
105
+ score_num: Shares = shares_sub(delta_shares, T_m, p)
106
+
107
+ # Line 9: [[Score_m^den]] ← SecureMult([[Δ]], [[|A(m)|]])
108
+ score_den: Shares = secure_mult(
109
+ delta_shares, A_m, n, t, p
110
+ )
111
+
112
+ scores.append((score_num, score_den))
113
+ A_shares.append(A_m)
114
+ T_tilde_shares.append(T_m)
115
+
116
+ return scores, A_shares, T_tilde_shares
@@ -0,0 +1,109 @@
1
+ """Algorithm 5 — Find Minimum Score.
2
+
3
+ Reference: Algorithm 5 in "Fairness Without Exposure: Privacy-Preserving
4
+ Phragmén Voting", Section 5.5 (page 12). Run every election period.
5
+
6
+ Finds the winning (minimum) score among all M candidates using a sequential
7
+ left-to-right reduction. At each step the running best is compared against
8
+ the next candidate via cross-multiplication to avoid division:
9
+
10
+ Score_m < Score_best iff Score_m^num · best^den < best^num · Score_m^den
11
+
12
+ Both cross-products are non-negative (Lemma 7.1), so SecureLT is valid.
13
+
14
+ Round complexity: O(M) — this sequential chain is the dominant latency
15
+ bottleneck of the per-period loop (paper Remark 6.1).
16
+ """
17
+
18
+ from typing import List, Tuple
19
+
20
+ from mpc_secret_shares import (
21
+ secure_mult,
22
+ secure_lt,
23
+ shares_add,
24
+ shares_sub,
25
+ )
26
+ from protocol.types import ScorePair, Shares
27
+
28
+
29
+ def algorithm_5_find_minimum_score(
30
+ scores: List[ScorePair],
31
+ n: int,
32
+ t: int,
33
+ p: int,
34
+ ) -> ScorePair:
35
+ """Find the shared winning (minimum) score across all candidates.
36
+
37
+ Algorithm 5 of "Fairness Without Exposure: Privacy-Preserving Phragmén
38
+ Voting", Section 5.5.
39
+
40
+ Maintains a running best ``(best^num, best^den)`` initialised to the
41
+ first candidate's score (Lines 1–2). For each subsequent candidate m
42
+ (Lines 3–8) the algorithm:
43
+
44
+ 1. Computes two cross-products to compare scores without division
45
+ (Lines 4–5).
46
+ 2. Calls SecureLT to get a shared bit β_m = 1_{Score_m < Score_best}
47
+ (Line 6).
48
+ 3. Updates the running best via a multiplexer:
49
+ ``best ← best + β_m · (Score_m − best)``
50
+ which selects Score_m when β_m = 1 and leaves best unchanged when 0
51
+ (Lines 7–8).
52
+
53
+ Args:
54
+ scores: List of M ScorePairs ``(Score_m^num, Score_m^den)`` produced
55
+ by Algorithm 4. Must contain at least one entry.
56
+ n: Number of MPC parties.
57
+ t: Reconstruction threshold.
58
+ p: Prime field modulus.
59
+
60
+ Returns:
61
+ A ScorePair ``(Score*^num, Score*^den)`` — a (t,n)-sharing of the
62
+ minimum score among all candidates.
63
+
64
+ Raises:
65
+ ValueError: If ``scores`` is empty (no candidates).
66
+ """
67
+ if not scores:
68
+ raise ValueError("scores must be non-empty: at least one candidate required")
69
+
70
+ # Lines 1-2: initialise the running best to the first candidate's score.
71
+ best_num: Shares = scores[0][0]
72
+ best_den: Shares = scores[0][1]
73
+
74
+ # Lines 3-8: compare each subsequent candidate against the running best.
75
+ for m in range(1, len(scores)): # Line 3
76
+ score_num: Shares = scores[m][0]
77
+ score_den: Shares = scores[m][1]
78
+
79
+ # Line 4: cross_a = Score_m^num · best^den
80
+ cross_a: Shares = secure_mult(
81
+ score_num, best_den, n, t, p
82
+ )
83
+
84
+ # Line 5: cross_b = best^num · Score_m^den
85
+ cross_b: Shares = secure_mult(
86
+ best_num, score_den, n, t, p
87
+ )
88
+
89
+ # Line 6: β_m = SecureLT(cross_a, cross_b)
90
+ # β_m = 1 iff Score_m < Score_best (candidate m is a better winner)
91
+ beta_m: Shares = secure_lt(cross_a, cross_b, n, t, p)
92
+
93
+ # Line 7: best^num ← best^num + β_m · (Score_m^num − best^num)
94
+ diff_num: Shares = shares_sub(score_num, best_num, p)
95
+ best_num = shares_add(
96
+ best_num,
97
+ secure_mult(beta_m, diff_num, n, t, p),
98
+ p,
99
+ )
100
+
101
+ # Line 8: best^den ← best^den + β_m · (Score_m^den − best^den)
102
+ diff_den: Shares = shares_sub(score_den, best_den, p)
103
+ best_den = shares_add(
104
+ best_den,
105
+ secure_mult(beta_m, diff_den, n, t, p),
106
+ p,
107
+ )
108
+
109
+ return best_num, best_den # Lines 9-10