pref_voting 1.16.31__py3-none-any.whl
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.
- pref_voting/__init__.py +1 -0
- pref_voting/analysis.py +496 -0
- pref_voting/axiom.py +38 -0
- pref_voting/axiom_helpers.py +129 -0
- pref_voting/axioms.py +10 -0
- pref_voting/c1_methods.py +963 -0
- pref_voting/combined_methods.py +514 -0
- pref_voting/create_methods.py +128 -0
- pref_voting/data/examples/condorcet_winner/minimal_Anti-Plurality.soc +16 -0
- pref_voting/data/examples/condorcet_winner/minimal_Borda.soc +17 -0
- pref_voting/data/examples/condorcet_winner/minimal_Bracket_Voting.soc +20 -0
- pref_voting/data/examples/condorcet_winner/minimal_Bucklin.soc +19 -0
- pref_voting/data/examples/condorcet_winner/minimal_Coombs.soc +20 -0
- pref_voting/data/examples/condorcet_winner/minimal_Coombs_PUT.soc +20 -0
- pref_voting/data/examples/condorcet_winner/minimal_Coombs_TB.soc +20 -0
- pref_voting/data/examples/condorcet_winner/minimal_Dowdall.soc +19 -0
- pref_voting/data/examples/condorcet_winner/minimal_Instant_Runoff.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_Instant_Runoff_PUT.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_Instant_Runoff_TB.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_Iterated_Removal_Condorcet_Loser.soc +17 -0
- pref_voting/data/examples/condorcet_winner/minimal_Pareto.soc +17 -0
- pref_voting/data/examples/condorcet_winner/minimal_Plurality.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_PluralityWRunoff_PUT.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_Positive-Negative_Voting.soc +17 -0
- pref_voting/data/examples/condorcet_winner/minimal_Simplified_Bucklin.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_Superior_Voting.soc +19 -0
- pref_voting/data/examples/condorcet_winner/minimal_Weighted_Bucklin.soc +19 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Anti-Plurality.soc +17 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Borda.soc +17 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Bracket_Voting.soc +20 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Bucklin.soc +19 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Coombs.soc +21 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Coombs_PUT.soc +21 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Coombs_TB.soc +20 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Dowdall.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Instant_Runoff.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Instant_Runoff_PUT.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Instant_Runoff_TB.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Plurality.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_PluralityWRunoff_PUT.soc +18 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Positive-Negative_Voting.soc +17 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Simplified_Bucklin.soc +20 -0
- pref_voting/data/examples/condorcet_winner/minimal_resolute_Weighted_Bucklin.soc +19 -0
- pref_voting/data/voting_methods_properties.json +414 -0
- pref_voting/data/voting_methods_properties.json.lock +0 -0
- pref_voting/dominance_axioms.py +387 -0
- pref_voting/generate_profiles.py +801 -0
- pref_voting/generate_spatial_profiles.py +198 -0
- pref_voting/generate_utility_profiles.py +160 -0
- pref_voting/generate_weighted_majority_graphs.py +506 -0
- pref_voting/grade_methods.py +184 -0
- pref_voting/grade_profiles.py +357 -0
- pref_voting/helper.py +370 -0
- pref_voting/invariance_axioms.py +671 -0
- pref_voting/io/__init__.py +0 -0
- pref_voting/io/readers.py +432 -0
- pref_voting/io/writers.py +256 -0
- pref_voting/iterative_methods.py +2425 -0
- pref_voting/maj_graph_ex1.png +0 -0
- pref_voting/mappings.py +577 -0
- pref_voting/margin_based_methods.py +2345 -0
- pref_voting/monotonicity_axioms.py +872 -0
- pref_voting/num_evaluation_method.py +77 -0
- pref_voting/other_axioms.py +161 -0
- pref_voting/other_methods.py +939 -0
- pref_voting/pairwise_profiles.py +547 -0
- pref_voting/prob_voting_method.py +105 -0
- pref_voting/probabilistic_methods.py +287 -0
- pref_voting/profiles.py +856 -0
- pref_voting/profiles_with_ties.py +1069 -0
- pref_voting/rankings.py +466 -0
- pref_voting/scoring_methods.py +481 -0
- pref_voting/social_welfare_function.py +59 -0
- pref_voting/social_welfare_functions.py +7 -0
- pref_voting/spatial_profiles.py +448 -0
- pref_voting/stochastic_methods.py +99 -0
- pref_voting/strategic_axioms.py +1394 -0
- pref_voting/swf_axioms.py +173 -0
- pref_voting/utility_functions.py +102 -0
- pref_voting/utility_methods.py +178 -0
- pref_voting/utility_profiles.py +333 -0
- pref_voting/variable_candidate_axioms.py +640 -0
- pref_voting/variable_voter_axioms.py +3747 -0
- pref_voting/voting_method.py +355 -0
- pref_voting/voting_method_properties.py +92 -0
- pref_voting/voting_methods.py +8 -0
- pref_voting/voting_methods_registry.py +136 -0
- pref_voting/weighted_majority_graphs.py +1539 -0
- pref_voting-1.16.31.dist-info/METADATA +208 -0
- pref_voting-1.16.31.dist-info/RECORD +92 -0
- pref_voting-1.16.31.dist-info/WHEEL +4 -0
- pref_voting-1.16.31.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,1394 @@
|
|
1
|
+
"""
|
2
|
+
File: strategic_axioms.py
|
3
|
+
Author: Wesley H. Holliday (wesholliday@berkeley.edu) and Eric Pacuit (epacuit@umd.edu)
|
4
|
+
Date: March 16, 2025
|
5
|
+
|
6
|
+
Strategic axioms
|
7
|
+
"""
|
8
|
+
|
9
|
+
import numpy as np
|
10
|
+
import math
|
11
|
+
from pref_voting.axiom import Axiom
|
12
|
+
from pref_voting.axiom_helpers import *
|
13
|
+
from itertools import permutations, combinations
|
14
|
+
from pref_voting.helper import weak_orders
|
15
|
+
from pref_voting.profiles import Profile
|
16
|
+
from pref_voting.profiles_with_ties import ProfileWithTies
|
17
|
+
from pref_voting.rankings import Ranking
|
18
|
+
|
19
|
+
def has_strategy_proofness_violation(prof, vm, set_preference = "single-winner", verbose=False):
|
20
|
+
"""
|
21
|
+
Returns True if there is a voter who can benefit by misrepresenting their preferences.
|
22
|
+
|
23
|
+
If set_preference = "single-winner", a voter benefits only if they can change the unique winner in the original profile to a unique winner in the new profile such that in their original ranking, the new winner is above the old winner.
|
24
|
+
|
25
|
+
If set_preference = "weak-dominance", a voter benefits only if in their original ranking, all new winners are weakly above all old winners and some new winner is strictly above some old winner.
|
26
|
+
|
27
|
+
If set_preference = "optimist", a voter benefits only if in their original ranking, their favorite new winner is above their favorite old winner.
|
28
|
+
|
29
|
+
If set_preference = "pessimist", a voter benefits only if in their original ranking, their least favorite new winner is above their least favorite old winner.
|
30
|
+
|
31
|
+
Args:
|
32
|
+
prof: a Profile or ProfileWithTies object.
|
33
|
+
vm (VotingMethod): A voting method to test.
|
34
|
+
verbose (bool, default=False): If a violation is found, display the violation.
|
35
|
+
|
36
|
+
Returns:
|
37
|
+
Result of the test (bool): Returns True if there is a violation and False otherwise.
|
38
|
+
|
39
|
+
.. note::
|
40
|
+
|
41
|
+
The different set preference notions are drawn from Definition 2.1.1 (p. 42) of The Mathematics of Manipulation by Alan D. Taylor.
|
42
|
+
"""
|
43
|
+
|
44
|
+
winners = vm(prof)
|
45
|
+
|
46
|
+
if isinstance(prof,ProfileWithTies):
|
47
|
+
prof.use_extended_strict_preference()
|
48
|
+
|
49
|
+
found_manipulator = False
|
50
|
+
|
51
|
+
ranking_tokens = prof.rankings
|
52
|
+
ranking_types = prof.ranking_types
|
53
|
+
|
54
|
+
ws = vm(prof)
|
55
|
+
|
56
|
+
if set_preference == "single-winner":
|
57
|
+
if len(ws) > 1:
|
58
|
+
return False
|
59
|
+
|
60
|
+
|
61
|
+
for r in ranking_types:
|
62
|
+
if not found_manipulator:
|
63
|
+
|
64
|
+
ranking_tokens_minus_r = [r for r in ranking_tokens]
|
65
|
+
ranking_tokens_minus_r.remove(r)
|
66
|
+
|
67
|
+
if isinstance(prof,Profile):
|
68
|
+
|
69
|
+
for new_r in permutations(prof.candidates):
|
70
|
+
if new_r != r and not found_manipulator:
|
71
|
+
|
72
|
+
new_ranking_tokens = ranking_tokens_minus_r + [new_r]
|
73
|
+
new_prof = Profile(new_ranking_tokens)
|
74
|
+
new_ws = vm(new_prof)
|
75
|
+
|
76
|
+
old_winner_to_compare = None
|
77
|
+
new_winner_to_compare = None
|
78
|
+
|
79
|
+
if set_preference == "single-winner" and len(new_ws) == 1:
|
80
|
+
|
81
|
+
old_winner_to_compare = ws[0]
|
82
|
+
new_winner_to_compare = new_ws[0]
|
83
|
+
|
84
|
+
elif set_preference == "weak-dominance":
|
85
|
+
r_as_ranking = Ranking({c: i for i, c in enumerate(r)})
|
86
|
+
|
87
|
+
elif set_preference == "optimist":
|
88
|
+
|
89
|
+
old_winner_to_compare = [cand for cand in r if cand in ws][0]
|
90
|
+
new_winner_to_compare = [cand for cand in r if cand in new_ws][0]
|
91
|
+
|
92
|
+
elif set_preference == "pessimist":
|
93
|
+
|
94
|
+
old_winner_to_compare = [cand for cand in r if cand in ws][-1]
|
95
|
+
new_winner_to_compare = [cand for cand in r if cand in new_ws][-1]
|
96
|
+
|
97
|
+
if old_winner_to_compare is not None and r.index(old_winner_to_compare) > r.index(new_winner_to_compare) or (set_preference == "weak-dominance" and r_as_ranking.weak_dom(new_ws,ws)):
|
98
|
+
|
99
|
+
found_manipulator = True
|
100
|
+
|
101
|
+
if verbose:
|
102
|
+
print(f"Violation of Strategy-Proofness for {vm.name} under the {set_preference} set preference.")
|
103
|
+
print(f"A voter can benefit by changing their ranking from {r} to {new_r}.")
|
104
|
+
print("")
|
105
|
+
print("Original Profile:")
|
106
|
+
prof.display()
|
107
|
+
print(prof.description())
|
108
|
+
print("")
|
109
|
+
vm.display(prof)
|
110
|
+
prof.display_margin_graph()
|
111
|
+
print("")
|
112
|
+
print("New Profile:")
|
113
|
+
new_prof.display()
|
114
|
+
print(new_prof.description())
|
115
|
+
print("")
|
116
|
+
vm.display(new_prof)
|
117
|
+
new_prof.display_margin_graph()
|
118
|
+
|
119
|
+
if isinstance(prof,ProfileWithTies):
|
120
|
+
r_dict = r.rmap
|
121
|
+
|
122
|
+
for _new_r in weak_orders(prof.candidates):
|
123
|
+
new_r = Ranking(_new_r)
|
124
|
+
if new_r != r and not found_manipulator:
|
125
|
+
|
126
|
+
new_ranking_tokens = ranking_tokens_minus_r + [new_r]
|
127
|
+
new_prof = ProfileWithTies(new_ranking_tokens, candidates = prof.candidates)
|
128
|
+
new_prof.use_extended_strict_preference()
|
129
|
+
new_ws = vm(new_prof)
|
130
|
+
|
131
|
+
ranked_old_winners = [c for c in ws if c in r_dict.keys()]
|
132
|
+
ranked_new_winners = [c for c in new_ws if c in r_dict.keys()]
|
133
|
+
|
134
|
+
rank_of_old_winner_to_compare = None
|
135
|
+
rank_of_new_winner_to_compare = None
|
136
|
+
|
137
|
+
if set_preference == "single-winner" and len(new_ws) == 1:
|
138
|
+
|
139
|
+
rank_of_old_winner_to_compare = r_dict[ws[0]] if ranked_old_winners else math.inf
|
140
|
+
rank_of_new_winner_to_compare = r_dict[new_ws[0]] if ranked_new_winners else math.inf
|
141
|
+
|
142
|
+
elif set_preference == "optimist":
|
143
|
+
|
144
|
+
rank_of_old_winner_to_compare = min([r_dict[c] for c in ranked_old_winners]) if ranked_old_winners else math.inf
|
145
|
+
rank_of_new_winner_to_compare = min([r_dict[c] for c in ranked_new_winners]) if ranked_new_winners else math.inf
|
146
|
+
|
147
|
+
elif set_preference == "pessimist":
|
148
|
+
|
149
|
+
rank_of_old_winner_to_compare = max([r_dict[c] for c in ranked_old_winners]) if ranked_old_winners == ws else math.inf
|
150
|
+
rank_of_new_winner_to_compare = max([r_dict[c] for c in ranked_new_winners]) if ranked_new_winners == new_ws else math.inf
|
151
|
+
|
152
|
+
if rank_of_old_winner_to_compare is not None and rank_of_old_winner_to_compare > rank_of_new_winner_to_compare or (set_preference == "weak-dominance" and r.weak_dom(new_ws,ws,use_extended_preferences=True)):
|
153
|
+
|
154
|
+
found_manipulator = True
|
155
|
+
|
156
|
+
if verbose:
|
157
|
+
print(f"Violation of Strategy-Proofness for {vm.name} under the {set_preference} set preference.")
|
158
|
+
print(f"A voter can benefit by changing their ranking from {r} to {new_r}.")
|
159
|
+
print("")
|
160
|
+
print("Original Profile:")
|
161
|
+
prof.display()
|
162
|
+
print(prof.description())
|
163
|
+
print("")
|
164
|
+
vm.display(prof)
|
165
|
+
prof.display_margin_graph()
|
166
|
+
print("")
|
167
|
+
print("New Profile:")
|
168
|
+
new_prof.display()
|
169
|
+
print(new_prof.description())
|
170
|
+
print("")
|
171
|
+
vm.display(new_prof)
|
172
|
+
new_prof.display_margin_graph()
|
173
|
+
|
174
|
+
return found_manipulator
|
175
|
+
|
176
|
+
def find_all_strategy_proofness_violations(prof, vm, set_preference = "single-winner", verbose=False):
|
177
|
+
"""
|
178
|
+
Returns a list of tuples (old_ranking, new_ranking) where old_ranking is the original ranking and new_ranking is the ranking that the voter can change to in order to benefit.
|
179
|
+
|
180
|
+
If set_preference = "single-winner", a voter benefits only if they can change the unique winner in the original profile to a unique winner in the new profile such that in their original ranking, the new winner is above the old winner.
|
181
|
+
|
182
|
+
If set_preference = "weak-dominance", a voter benefits only if in their original ranking, all new winners are weakly above all old winners and some new winner is strictly above some old winner.
|
183
|
+
|
184
|
+
If set_preference = "optimist", a voter benefits only if in their original ranking, their favorite new winner is above their favorite old winner.
|
185
|
+
|
186
|
+
If set_preference = "pessimist", a voter benefits only if in their original ranking, their least favorite new winner is above their least favorite old winner.
|
187
|
+
|
188
|
+
Args:
|
189
|
+
prof: a Profile or ProfileWithTies object.
|
190
|
+
vm (VotingMethod): A voting method to test.
|
191
|
+
verbose (bool, default=False): If a violation is found, display the violation.
|
192
|
+
|
193
|
+
Returns:
|
194
|
+
A List of tuples (old_ranking, new_ranking) where old_ranking is the original ranking and new_ranking is the ranking that the voter can change to in order to benefit.
|
195
|
+
"""
|
196
|
+
|
197
|
+
winners = vm(prof)
|
198
|
+
|
199
|
+
if isinstance(prof,ProfileWithTies):
|
200
|
+
prof.use_extended_strict_preference()
|
201
|
+
|
202
|
+
violations = list()
|
203
|
+
|
204
|
+
ranking_tokens = prof.rankings
|
205
|
+
ranking_types = prof.ranking_types
|
206
|
+
|
207
|
+
ws = vm(prof)
|
208
|
+
|
209
|
+
if set_preference == "single-winner":
|
210
|
+
if len(ws) > 1:
|
211
|
+
return violations
|
212
|
+
|
213
|
+
for r in ranking_types:
|
214
|
+
ranking_tokens_minus_r = [r for r in ranking_tokens]
|
215
|
+
ranking_tokens_minus_r.remove(r)
|
216
|
+
|
217
|
+
if isinstance(prof,Profile):
|
218
|
+
|
219
|
+
for new_r in permutations(prof.candidates):
|
220
|
+
if new_r != r:
|
221
|
+
|
222
|
+
new_ranking_tokens = ranking_tokens_minus_r + [new_r]
|
223
|
+
new_prof = Profile(new_ranking_tokens)
|
224
|
+
new_ws = vm(new_prof)
|
225
|
+
|
226
|
+
old_winner_to_compare = None
|
227
|
+
new_winner_to_compare = None
|
228
|
+
|
229
|
+
if set_preference == "single-winner" and len(new_ws) == 1:
|
230
|
+
|
231
|
+
old_winner_to_compare = ws[0]
|
232
|
+
new_winner_to_compare = new_ws[0]
|
233
|
+
|
234
|
+
elif set_preference == "weak-dominance":
|
235
|
+
r_as_ranking = Ranking({c: i for i, c in enumerate(r)})
|
236
|
+
|
237
|
+
elif set_preference == "optimist":
|
238
|
+
|
239
|
+
old_winner_to_compare = [cand for cand in r if cand in ws][0]
|
240
|
+
new_winner_to_compare = [cand for cand in r if cand in new_ws][0]
|
241
|
+
|
242
|
+
elif set_preference == "pessimist":
|
243
|
+
|
244
|
+
old_winner_to_compare = [cand for cand in r if cand in ws][-1]
|
245
|
+
new_winner_to_compare = [cand for cand in r if cand in new_ws][-1]
|
246
|
+
|
247
|
+
if old_winner_to_compare is not None and r.index(old_winner_to_compare) > r.index(new_winner_to_compare) or (set_preference == "weak-dominance" and r_as_ranking.weak_dom(new_ws,ws)):
|
248
|
+
|
249
|
+
violations.append((r,new_r))
|
250
|
+
|
251
|
+
if verbose:
|
252
|
+
print(f"Violation of Strategy-Proofness for {vm.name} under the {set_preference} set preference.")
|
253
|
+
print(f"A voter can benefit by changing their ranking from {r} to {new_r}.")
|
254
|
+
print("")
|
255
|
+
print("Original Profile:")
|
256
|
+
prof.display()
|
257
|
+
print(prof.description())
|
258
|
+
print("")
|
259
|
+
vm.display(prof)
|
260
|
+
prof.display_margin_graph()
|
261
|
+
print("")
|
262
|
+
print("New Profile:")
|
263
|
+
new_prof.display()
|
264
|
+
print(new_prof.description())
|
265
|
+
print("")
|
266
|
+
vm.display(new_prof)
|
267
|
+
new_prof.display_margin_graph()
|
268
|
+
|
269
|
+
if isinstance(prof,ProfileWithTies):
|
270
|
+
r_dict = r.rmap
|
271
|
+
|
272
|
+
for _new_r in weak_orders(prof.candidates):
|
273
|
+
new_r = Ranking(_new_r)
|
274
|
+
if new_r != r:
|
275
|
+
|
276
|
+
new_ranking_tokens = ranking_tokens_minus_r + [new_r]
|
277
|
+
new_prof = ProfileWithTies(new_ranking_tokens, candidates = prof.candidates)
|
278
|
+
new_prof.use_extended_strict_preference()
|
279
|
+
new_ws = vm(new_prof)
|
280
|
+
|
281
|
+
ranked_old_winners = [c for c in ws if c in r_dict.keys()]
|
282
|
+
ranked_new_winners = [c for c in new_ws if c in r_dict.keys()]
|
283
|
+
|
284
|
+
rank_of_old_winner_to_compare = None
|
285
|
+
rank_of_new_winner_to_compare = None
|
286
|
+
|
287
|
+
if set_preference == "single-winner" and len(new_ws) == 1:
|
288
|
+
|
289
|
+
rank_of_old_winner_to_compare = r_dict[ws[0]] if ranked_old_winners else math.inf
|
290
|
+
rank_of_new_winner_to_compare = r_dict[new_ws[0]] if ranked_new_winners else math.inf
|
291
|
+
|
292
|
+
elif set_preference == "optimist":
|
293
|
+
|
294
|
+
rank_of_old_winner_to_compare = min([r_dict[c] for c in ranked_old_winners]) if ranked_old_winners else math.inf
|
295
|
+
rank_of_new_winner_to_compare = min([r_dict[c] for c in ranked_new_winners]) if ranked_new_winners else math.inf
|
296
|
+
|
297
|
+
elif set_preference == "pessimist":
|
298
|
+
|
299
|
+
rank_of_old_winner_to_compare = max([r_dict[c] for c in ranked_old_winners]) if ranked_old_winners == ws else math.inf
|
300
|
+
rank_of_new_winner_to_compare = max([r_dict[c] for c in ranked_new_winners]) if ranked_new_winners == new_ws else math.inf
|
301
|
+
|
302
|
+
if rank_of_old_winner_to_compare is not None and rank_of_old_winner_to_compare > rank_of_new_winner_to_compare or (set_preference == "weak-dominance" and r.weak_dom(new_ws,ws,use_extended_preferences=True)):
|
303
|
+
|
304
|
+
violations.append((r.rmap,new_r.rmap))
|
305
|
+
|
306
|
+
if verbose:
|
307
|
+
print(f"Violation of Strategy-Proofness for {vm.name} under the {set_preference} set preference.")
|
308
|
+
print(f"A voter can benefit by changing their ranking from {r} to {new_r}.")
|
309
|
+
print("")
|
310
|
+
print("Original Profile:")
|
311
|
+
prof.display()
|
312
|
+
print(prof.description())
|
313
|
+
print("")
|
314
|
+
vm.display(prof)
|
315
|
+
prof.display_margin_graph()
|
316
|
+
print("")
|
317
|
+
print("New Profile:")
|
318
|
+
new_prof.display()
|
319
|
+
print(new_prof.description())
|
320
|
+
print("")
|
321
|
+
vm.display(new_prof)
|
322
|
+
new_prof.display_margin_graph()
|
323
|
+
|
324
|
+
return violations
|
325
|
+
|
326
|
+
strategy_proofness = Axiom(
|
327
|
+
"Strategy Proofness",
|
328
|
+
has_violation = has_strategy_proofness_violation,
|
329
|
+
find_all_violations = find_all_strategy_proofness_violations,
|
330
|
+
)
|
331
|
+
|
332
|
+
def truncate_ballot_from_bottom(ranking, num_to_keep):
|
333
|
+
"""
|
334
|
+
Truncate a ballot by keeping only the top num_to_keep candidates.
|
335
|
+
|
336
|
+
Args:
|
337
|
+
ranking: A Ranking object or tuple representing a ballot
|
338
|
+
num_to_keep: Number of top candidates to keep
|
339
|
+
|
340
|
+
Returns:
|
341
|
+
A truncated Ranking object
|
342
|
+
"""
|
343
|
+
if isinstance(ranking, tuple):
|
344
|
+
# For tuple rankings, keep only the first num_to_keep candidates
|
345
|
+
truncated = ranking[:num_to_keep]
|
346
|
+
return Ranking({c: i+1 for i, c in enumerate(truncated)})
|
347
|
+
else:
|
348
|
+
# For Ranking objects, sort candidates by rank and keep only the top num_to_keep
|
349
|
+
sorted_candidates = sorted(ranking.rmap.keys(), key=lambda c: ranking.rmap[c])
|
350
|
+
truncated_rmap = {c: ranking.rmap[c] for c in sorted_candidates[:num_to_keep]}
|
351
|
+
return Ranking(truncated_rmap)
|
352
|
+
|
353
|
+
def has_later_no_harm_violation(prof, vm, verbose=False, coalition_size=1, uniform_coalition=True, require_resoluteness=False):
|
354
|
+
"""
|
355
|
+
Returns True if there is a ballot (or collection of ballots) such that by truncating it from the bottom,
|
356
|
+
a candidate who is ranked by the truncated ballot goes from losing to winning.
|
357
|
+
|
358
|
+
Viewed in reverse, this means that adding previously unranked candidates to the bottom of a ballot harmed a higher ranked candidate.
|
359
|
+
|
360
|
+
Args:
|
361
|
+
prof: a Profile or ProfileWithTies object.
|
362
|
+
vm (VotingMethod): A voting method to test.
|
363
|
+
verbose (bool, default=False): If a violation is found, display the violation.
|
364
|
+
coalition_size (int, default=1): Size of the coalition of voters who truncate their ballots.
|
365
|
+
uniform_coalition (bool, default=True): If True, all voters in the coalition have the same ballot.
|
366
|
+
require_resoluteness (bool, default=False): If True, only profiles with a unique winner before and after truncation are considered.
|
367
|
+
|
368
|
+
Returns:
|
369
|
+
Result of the test (bool): Returns True if there is a violation and False otherwise.
|
370
|
+
"""
|
371
|
+
# Get the current winners
|
372
|
+
winners = vm(prof)
|
373
|
+
|
374
|
+
# Convert numpy array winners to list if needed
|
375
|
+
if isinstance(winners, np.ndarray):
|
376
|
+
winners = winners.tolist()
|
377
|
+
|
378
|
+
# If require_resoluteness is True, skip profiles with multiple winners before truncation
|
379
|
+
if require_resoluteness and len(winners) > 1:
|
380
|
+
return False
|
381
|
+
|
382
|
+
# For individual voter case or uniform coalition
|
383
|
+
if uniform_coalition:
|
384
|
+
# Check each ranking type in the profile
|
385
|
+
for r in prof.ranking_types:
|
386
|
+
# Skip if there aren't enough voters with this ranking for the coalition
|
387
|
+
if isinstance(r, tuple):
|
388
|
+
if prof.rankings.count(r) < coalition_size:
|
389
|
+
continue
|
390
|
+
ranked_candidates = list(r)
|
391
|
+
r_as_ranking = Ranking({c: i+1 for i, c in enumerate(r)})
|
392
|
+
else:
|
393
|
+
if sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands) < coalition_size:
|
394
|
+
continue
|
395
|
+
ranked_candidates = list(r.cands)
|
396
|
+
r_as_ranking = r
|
397
|
+
|
398
|
+
# Try truncating at different positions (keep only first i candidates)
|
399
|
+
for i in range(1, len(ranked_candidates)):
|
400
|
+
# Create truncated ballot keeping only the first i candidates
|
401
|
+
# This is proper truncation - keeping the top i candidates in their original order
|
402
|
+
truncated_r = truncate_ballot_from_bottom(r_as_ranking, i)
|
403
|
+
|
404
|
+
# Skip if truncation didn't change anything or if truncated ballot is empty
|
405
|
+
# Ensure at least one candidate remains ranked
|
406
|
+
if len(truncated_r.cands) == 0 or truncated_r.rmap == r_as_ranking.rmap:
|
407
|
+
continue
|
408
|
+
|
409
|
+
# Get the truncated ballot as a ranking map
|
410
|
+
truncated_rmap = truncated_r.rmap
|
411
|
+
|
412
|
+
# Create a new profile with the truncated ballot(s)
|
413
|
+
modified_rankings = []
|
414
|
+
|
415
|
+
# Add all ballots except those we'll truncate
|
416
|
+
for ballot in prof.rankings:
|
417
|
+
# Skip the ballots we'll truncate
|
418
|
+
if isinstance(ballot, tuple) and ballot == r:
|
419
|
+
continue
|
420
|
+
elif isinstance(ballot, Ranking) and isinstance(r, Ranking) and ballot.cands == r.cands:
|
421
|
+
continue
|
422
|
+
|
423
|
+
# Add the ballot in the correct format
|
424
|
+
if isinstance(ballot, tuple):
|
425
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(ballot)})
|
426
|
+
else:
|
427
|
+
modified_rankings.append(ballot.rmap)
|
428
|
+
|
429
|
+
# Make sure we've skipped the right number of ballots
|
430
|
+
if isinstance(r, tuple):
|
431
|
+
original_count = prof.rankings.count(r)
|
432
|
+
else:
|
433
|
+
original_count = sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands)
|
434
|
+
|
435
|
+
# Add back any ballots we shouldn't have truncated
|
436
|
+
for _ in range(original_count - coalition_size):
|
437
|
+
modified_rankings.append(r_as_ranking.rmap)
|
438
|
+
|
439
|
+
# Add the truncated ballots
|
440
|
+
for _ in range(coalition_size):
|
441
|
+
modified_rankings.append(truncated_rmap)
|
442
|
+
|
443
|
+
# Create the new profile with ties
|
444
|
+
new_prof = ProfileWithTies(modified_rankings, candidates=prof.candidates)
|
445
|
+
if isinstance(prof, ProfileWithTies) and prof.using_extended_strict_preference:
|
446
|
+
new_prof.use_extended_strict_preference()
|
447
|
+
|
448
|
+
# Get the new winners
|
449
|
+
new_winners = vm(new_prof)
|
450
|
+
|
451
|
+
# Convert numpy array winners to list if needed
|
452
|
+
if isinstance(new_winners, np.ndarray):
|
453
|
+
new_winners = new_winners.tolist()
|
454
|
+
|
455
|
+
# If require_resoluteness is True, skip profiles with multiple winners after truncation
|
456
|
+
if require_resoluteness and len(new_winners) > 1:
|
457
|
+
continue
|
458
|
+
|
459
|
+
# Check for Later No Harm violation: a candidate ranked in the truncated ballot
|
460
|
+
# goes from losing to winning
|
461
|
+
truncated_candidates = truncated_r.cands
|
462
|
+
|
463
|
+
# Find candidates that are ranked in the truncated ballot and went from losing to winning
|
464
|
+
new_winners_in_truncated = [c for c in new_winners if c in truncated_candidates and c not in winners]
|
465
|
+
|
466
|
+
if new_winners_in_truncated:
|
467
|
+
if verbose:
|
468
|
+
print(f"Later No Harm violation: Candidate(s) {new_winners_in_truncated} went from losing to winning due to bottom truncation.")
|
469
|
+
print("")
|
470
|
+
print(f"Original winners: {winners}")
|
471
|
+
print(f"New winners: {new_winners}")
|
472
|
+
print(f"Original ballot: {r}")
|
473
|
+
print(f"Truncated ballot: {truncated_r}")
|
474
|
+
print("")
|
475
|
+
print("Original profile:")
|
476
|
+
prof.display()
|
477
|
+
prof.display_margin_graph()
|
478
|
+
vm.display(prof)
|
479
|
+
print("")
|
480
|
+
print("Modified profile:")
|
481
|
+
new_prof.display()
|
482
|
+
new_prof.display_margin_graph()
|
483
|
+
vm.display(new_prof)
|
484
|
+
return True
|
485
|
+
|
486
|
+
# For non-uniform coalition
|
487
|
+
if not uniform_coalition and coalition_size > 1:
|
488
|
+
# Get all possible combinations of ranking types
|
489
|
+
ranking_combinations = list(combinations(prof.ranking_types, coalition_size))
|
490
|
+
|
491
|
+
for ranking_combo in ranking_combinations:
|
492
|
+
# Check if we have enough of each ranking type
|
493
|
+
valid_combo = True
|
494
|
+
for r in ranking_combo:
|
495
|
+
if isinstance(r, tuple):
|
496
|
+
if prof.rankings.count(r) < ranking_combo.count(r):
|
497
|
+
valid_combo = False
|
498
|
+
break
|
499
|
+
else:
|
500
|
+
if sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands) < ranking_combo.count(r):
|
501
|
+
valid_combo = False
|
502
|
+
break
|
503
|
+
|
504
|
+
if not valid_combo:
|
505
|
+
continue
|
506
|
+
|
507
|
+
# For each ranking in the combination, try truncating it
|
508
|
+
for i, r in enumerate(ranking_combo):
|
509
|
+
if isinstance(r, tuple):
|
510
|
+
ranked_candidates = list(r)
|
511
|
+
r_as_ranking = Ranking({c: i+1 for i, c in enumerate(r)})
|
512
|
+
else:
|
513
|
+
ranked_candidates = list(r.cands)
|
514
|
+
r_as_ranking = r
|
515
|
+
|
516
|
+
# Try truncating at different positions
|
517
|
+
for j in range(1, len(ranked_candidates)):
|
518
|
+
# Create truncated ballot keeping only the first j candidates
|
519
|
+
# This is proper truncation - keeping the top j candidates in their original order
|
520
|
+
truncated_r = truncate_ballot_from_bottom(r_as_ranking, j)
|
521
|
+
|
522
|
+
# Skip if truncation didn't change anything or if truncated ballot is empty
|
523
|
+
if len(truncated_r.cands) == 0 or truncated_r.rmap == r_as_ranking.rmap:
|
524
|
+
continue
|
525
|
+
|
526
|
+
# Get the truncated ballot as a ranking map
|
527
|
+
truncated_rmap = truncated_r.rmap
|
528
|
+
|
529
|
+
# Create a new profile with the truncated ballot(s)
|
530
|
+
modified_rankings = []
|
531
|
+
|
532
|
+
# Add all ballots except those in the coalition
|
533
|
+
for ballot in prof.rankings:
|
534
|
+
skip = False
|
535
|
+
for coalition_r in ranking_combo:
|
536
|
+
if (isinstance(ballot, tuple) and ballot == coalition_r) or (isinstance(ballot, Ranking) and isinstance(coalition_r, Ranking) and ballot.cands == coalition_r.cands):
|
537
|
+
skip = True
|
538
|
+
break
|
539
|
+
|
540
|
+
if skip:
|
541
|
+
continue
|
542
|
+
|
543
|
+
# Add the ballot in the correct format
|
544
|
+
if isinstance(ballot, tuple):
|
545
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(ballot)})
|
546
|
+
else:
|
547
|
+
modified_rankings.append(ballot.rmap)
|
548
|
+
|
549
|
+
# Add back the coalition ballots, with the i-th one truncated
|
550
|
+
for k, coalition_r in enumerate(ranking_combo):
|
551
|
+
if k == i:
|
552
|
+
modified_rankings.append(truncated_rmap)
|
553
|
+
else:
|
554
|
+
if isinstance(coalition_r, tuple):
|
555
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(coalition_r)})
|
556
|
+
else:
|
557
|
+
modified_rankings.append(coalition_r.rmap)
|
558
|
+
|
559
|
+
# Create the new profile with ties
|
560
|
+
new_prof = ProfileWithTies(modified_rankings, candidates=prof.candidates)
|
561
|
+
if isinstance(prof, ProfileWithTies) and prof.using_extended_strict_preference:
|
562
|
+
new_prof.use_extended_strict_preference()
|
563
|
+
|
564
|
+
# Get the new winners
|
565
|
+
new_winners = vm(new_prof)
|
566
|
+
|
567
|
+
# Convert numpy array winners to list if needed
|
568
|
+
if isinstance(new_winners, np.ndarray):
|
569
|
+
new_winners = new_winners.tolist()
|
570
|
+
|
571
|
+
# If require_resoluteness is True, skip profiles with multiple winners after truncation
|
572
|
+
if require_resoluteness and len(new_winners) > 1:
|
573
|
+
continue
|
574
|
+
|
575
|
+
# Check for Later No Harm violation: a candidate ranked in the truncated ballot
|
576
|
+
# goes from losing to winning
|
577
|
+
truncated_candidates = truncated_r.cands
|
578
|
+
|
579
|
+
# Find candidates that are ranked in the truncated ballot and went from losing to winning
|
580
|
+
new_winners_in_truncated = [c for c in new_winners if c in truncated_candidates and c not in winners]
|
581
|
+
|
582
|
+
if new_winners_in_truncated:
|
583
|
+
if verbose:
|
584
|
+
print(f"Later No Harm violation: Candidate(s) {new_winners_in_truncated} went from losing to winning due to bottom truncation.")
|
585
|
+
print("")
|
586
|
+
print(f"Original winners: {winners}")
|
587
|
+
print(f"New winners: {new_winners}")
|
588
|
+
print(f"Original ballot: {r}")
|
589
|
+
print(f"Truncated ballot: {truncated_r}")
|
590
|
+
print("")
|
591
|
+
print("Original profile:")
|
592
|
+
prof.display()
|
593
|
+
prof.display_margin_graph()
|
594
|
+
vm.display(prof)
|
595
|
+
print("")
|
596
|
+
print("Modified profile:")
|
597
|
+
new_prof.display()
|
598
|
+
new_prof.display_margin_graph()
|
599
|
+
vm.display(new_prof)
|
600
|
+
return True
|
601
|
+
|
602
|
+
return False
|
603
|
+
|
604
|
+
def find_all_later_no_harm_violations(prof, vm, verbose=False, coalition_size=1, uniform_coalition=True, require_resoluteness=False):
|
605
|
+
"""
|
606
|
+
Returns a list of tuples (original_ballot, truncated_ballot, original_winners, new_winners, new_winners_in_truncated) such that bottom-truncating the original_ballot to the truncated_ballot causes a candidate ranked in the truncated ballot to go from losing to winning.
|
607
|
+
|
608
|
+
Args:
|
609
|
+
prof: a Profile or ProfileWithTies object.
|
610
|
+
vm (VotingMethod): A voting method to test.
|
611
|
+
verbose (bool, default=False): If a violation is found, display the violation.
|
612
|
+
coalition_size (int, default=1): Size of the coalition of voters who truncate their ballots.
|
613
|
+
uniform_coalition (bool, default=True): If True, all voters in the coalition have the same ballot.
|
614
|
+
require_resoluteness (bool, default=False): If True, only profiles with a unique winner before and after truncation are considered.
|
615
|
+
|
616
|
+
Returns:
|
617
|
+
A list of tuples (original_ballot, truncated_ballot, original_winners, new_winners, new_winners_in_truncated)
|
618
|
+
witnessing violations of Later No Harm. The new_winners_in_truncated element contains the candidates that
|
619
|
+
specifically caused the violation by going from losing to winning while being ranked in the truncated ballot.
|
620
|
+
"""
|
621
|
+
violations = []
|
622
|
+
|
623
|
+
# Get the current winners
|
624
|
+
winners = vm(prof)
|
625
|
+
|
626
|
+
# Convert numpy array winners to list if needed
|
627
|
+
if isinstance(winners, np.ndarray):
|
628
|
+
winners = winners.tolist()
|
629
|
+
|
630
|
+
# If require_resoluteness is True, skip profiles with multiple winners before truncation
|
631
|
+
if require_resoluteness and len(winners) > 1:
|
632
|
+
return violations
|
633
|
+
|
634
|
+
# For individual voter case or uniform coalition
|
635
|
+
if uniform_coalition:
|
636
|
+
# Check each ranking type in the profile
|
637
|
+
for r in prof.ranking_types:
|
638
|
+
# Skip if there aren't enough voters with this ranking for the coalition
|
639
|
+
if isinstance(r, tuple):
|
640
|
+
if prof.rankings.count(r) < coalition_size:
|
641
|
+
continue
|
642
|
+
ranked_candidates = list(r)
|
643
|
+
r_as_ranking = Ranking({c: i+1 for i, c in enumerate(r)})
|
644
|
+
else:
|
645
|
+
if sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands) < coalition_size:
|
646
|
+
continue
|
647
|
+
ranked_candidates = list(r.cands)
|
648
|
+
r_as_ranking = r
|
649
|
+
|
650
|
+
# Try truncating at different positions (keep only first i candidates)
|
651
|
+
for i in range(1, len(ranked_candidates)):
|
652
|
+
# Create truncated ballot keeping only the first i candidates
|
653
|
+
# This is proper truncation - keeping the top i candidates in their original order
|
654
|
+
truncated_r = truncate_ballot_from_bottom(r_as_ranking, i)
|
655
|
+
|
656
|
+
# Skip if truncation didn't change anything or if truncated ballot is empty
|
657
|
+
# Ensure at least one candidate remains ranked
|
658
|
+
if len(truncated_r.cands) == 0 or truncated_r.rmap == r_as_ranking.rmap:
|
659
|
+
continue
|
660
|
+
|
661
|
+
# Get the truncated ballot as a ranking map
|
662
|
+
truncated_rmap = truncated_r.rmap
|
663
|
+
|
664
|
+
# Create a new profile with the truncated ballot(s)
|
665
|
+
modified_rankings = []
|
666
|
+
|
667
|
+
# Add all ballots except those we'll truncate
|
668
|
+
for ballot in prof.rankings:
|
669
|
+
# Skip the ballots we'll truncate
|
670
|
+
if isinstance(ballot, tuple) and ballot == r:
|
671
|
+
continue
|
672
|
+
elif isinstance(ballot, Ranking) and isinstance(r, Ranking) and ballot.cands == r.cands:
|
673
|
+
continue
|
674
|
+
|
675
|
+
# Add the ballot in the correct format
|
676
|
+
if isinstance(ballot, tuple):
|
677
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(ballot)})
|
678
|
+
else:
|
679
|
+
modified_rankings.append(ballot.rmap)
|
680
|
+
|
681
|
+
# Make sure we've skipped the right number of ballots
|
682
|
+
if isinstance(r, tuple):
|
683
|
+
original_count = prof.rankings.count(r)
|
684
|
+
else:
|
685
|
+
original_count = sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands)
|
686
|
+
|
687
|
+
# Add back any ballots we shouldn't have truncated
|
688
|
+
for _ in range(original_count - coalition_size):
|
689
|
+
modified_rankings.append(r_as_ranking.rmap)
|
690
|
+
|
691
|
+
# Add the truncated ballots
|
692
|
+
for _ in range(coalition_size):
|
693
|
+
modified_rankings.append(truncated_rmap)
|
694
|
+
|
695
|
+
# Create the new profile with ties
|
696
|
+
new_prof = ProfileWithTies(modified_rankings, candidates=prof.candidates)
|
697
|
+
if isinstance(prof, ProfileWithTies) and prof.using_extended_strict_preference:
|
698
|
+
new_prof.use_extended_strict_preference()
|
699
|
+
|
700
|
+
# Get the new winners
|
701
|
+
new_winners = vm(new_prof)
|
702
|
+
|
703
|
+
# Convert numpy array winners to list if needed
|
704
|
+
if isinstance(new_winners, np.ndarray):
|
705
|
+
new_winners = new_winners.tolist()
|
706
|
+
|
707
|
+
# If require_resoluteness is True, skip profiles with multiple winners after truncation
|
708
|
+
if require_resoluteness and len(new_winners) > 1:
|
709
|
+
continue
|
710
|
+
|
711
|
+
# Check for Later No Harm violation: a candidate ranked in the truncated ballot
|
712
|
+
# goes from losing to winning
|
713
|
+
truncated_candidates = truncated_r.cands
|
714
|
+
|
715
|
+
# Find candidates that are ranked in the truncated ballot and went from losing to winning
|
716
|
+
new_winners_in_truncated = [c for c in new_winners if c in truncated_candidates and c not in winners]
|
717
|
+
|
718
|
+
if new_winners_in_truncated:
|
719
|
+
violations.append((r, truncated_r, winners, new_winners, new_winners_in_truncated))
|
720
|
+
if verbose:
|
721
|
+
print(f"Later No Harm violation: Candidate(s) {new_winners_in_truncated} went from losing to winning due to bottom truncation.")
|
722
|
+
print("")
|
723
|
+
print(f"Original winners: {winners}")
|
724
|
+
print(f"New winners: {new_winners}")
|
725
|
+
print(f"Original ballot: {r}")
|
726
|
+
print(f"Truncated ballot: {truncated_r}")
|
727
|
+
print("")
|
728
|
+
print("Original profile:")
|
729
|
+
prof.display()
|
730
|
+
prof.display_margin_graph()
|
731
|
+
vm.display(prof)
|
732
|
+
print("")
|
733
|
+
print("Modified profile:")
|
734
|
+
new_prof.display()
|
735
|
+
new_prof.display_margin_graph()
|
736
|
+
vm.display(new_prof)
|
737
|
+
|
738
|
+
# For non-uniform coalition
|
739
|
+
if not uniform_coalition and coalition_size > 1:
|
740
|
+
# Get all possible combinations of ranking types
|
741
|
+
ranking_combinations = list(combinations(prof.ranking_types, coalition_size))
|
742
|
+
|
743
|
+
for ranking_combo in ranking_combinations:
|
744
|
+
# Check if we have enough of each ranking type
|
745
|
+
valid_combo = True
|
746
|
+
for r in ranking_combo:
|
747
|
+
if isinstance(r, tuple):
|
748
|
+
if prof.rankings.count(r) < ranking_combo.count(r):
|
749
|
+
valid_combo = False
|
750
|
+
break
|
751
|
+
else:
|
752
|
+
if sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands) < ranking_combo.count(r):
|
753
|
+
valid_combo = False
|
754
|
+
break
|
755
|
+
|
756
|
+
if not valid_combo:
|
757
|
+
continue
|
758
|
+
|
759
|
+
# For each ranking in the combination, try truncating it
|
760
|
+
for i, r in enumerate(ranking_combo):
|
761
|
+
if isinstance(r, tuple):
|
762
|
+
ranked_candidates = list(r)
|
763
|
+
r_as_ranking = Ranking({c: i+1 for i, c in enumerate(r)})
|
764
|
+
else:
|
765
|
+
ranked_candidates = list(r.cands)
|
766
|
+
r_as_ranking = r
|
767
|
+
|
768
|
+
# Try truncating at different positions
|
769
|
+
for j in range(1, len(ranked_candidates)):
|
770
|
+
# Create truncated ballot keeping only the first j candidates
|
771
|
+
# This is proper truncation - keeping the top j candidates in their original order
|
772
|
+
truncated_r = truncate_ballot_from_bottom(r_as_ranking, j)
|
773
|
+
|
774
|
+
# Skip if truncation didn't change anything or if truncated ballot is empty
|
775
|
+
if len(truncated_r.cands) == 0 or truncated_r.rmap == r_as_ranking.rmap:
|
776
|
+
continue
|
777
|
+
|
778
|
+
# Get the truncated ballot as a ranking map
|
779
|
+
truncated_rmap = truncated_r.rmap
|
780
|
+
|
781
|
+
# Create a new profile with the truncated ballot(s)
|
782
|
+
modified_rankings = []
|
783
|
+
|
784
|
+
# Add all ballots except those in the coalition
|
785
|
+
for ballot in prof.rankings:
|
786
|
+
skip = False
|
787
|
+
for coalition_r in ranking_combo:
|
788
|
+
if (isinstance(ballot, tuple) and ballot == coalition_r) or (isinstance(ballot, Ranking) and isinstance(coalition_r, Ranking) and ballot.cands == coalition_r.cands):
|
789
|
+
skip = True
|
790
|
+
break
|
791
|
+
|
792
|
+
if skip:
|
793
|
+
continue
|
794
|
+
|
795
|
+
# Add the ballot in the correct format
|
796
|
+
if isinstance(ballot, tuple):
|
797
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(ballot)})
|
798
|
+
else:
|
799
|
+
modified_rankings.append(ballot.rmap)
|
800
|
+
|
801
|
+
# Add back the coalition ballots, with the i-th one truncated
|
802
|
+
for k, coalition_r in enumerate(ranking_combo):
|
803
|
+
if k == i:
|
804
|
+
modified_rankings.append(truncated_rmap)
|
805
|
+
else:
|
806
|
+
if isinstance(coalition_r, tuple):
|
807
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(coalition_r)})
|
808
|
+
else:
|
809
|
+
modified_rankings.append(coalition_r.rmap)
|
810
|
+
|
811
|
+
# Create the new profile with ties
|
812
|
+
new_prof = ProfileWithTies(modified_rankings, candidates=prof.candidates)
|
813
|
+
if isinstance(prof, ProfileWithTies) and prof.using_extended_strict_preference:
|
814
|
+
new_prof.use_extended_strict_preference()
|
815
|
+
|
816
|
+
# Get the new winners
|
817
|
+
new_winners = vm(new_prof)
|
818
|
+
|
819
|
+
# Convert numpy array winners to list if needed
|
820
|
+
if isinstance(new_winners, np.ndarray):
|
821
|
+
new_winners = new_winners.tolist()
|
822
|
+
|
823
|
+
# If require_resoluteness is True, skip profiles with multiple winners after truncation
|
824
|
+
if require_resoluteness and len(new_winners) > 1:
|
825
|
+
continue
|
826
|
+
|
827
|
+
# Check for Later No Harm violation: a candidate ranked in the truncated ballot
|
828
|
+
# goes from losing to winning
|
829
|
+
truncated_candidates = truncated_r.cands
|
830
|
+
|
831
|
+
# Find candidates that are ranked in the truncated ballot and went from losing to winning
|
832
|
+
new_winners_in_truncated = [c for c in new_winners if c in truncated_candidates and c not in winners]
|
833
|
+
|
834
|
+
if new_winners_in_truncated:
|
835
|
+
violations.append((r, truncated_r, winners, new_winners, new_winners_in_truncated))
|
836
|
+
if verbose:
|
837
|
+
print(f"Later No Harm violation: Candidate(s) {new_winners_in_truncated} went from losing to winning due to bottom truncation.")
|
838
|
+
print("")
|
839
|
+
print(f"Original winners: {winners}")
|
840
|
+
print(f"New winners: {new_winners}")
|
841
|
+
print(f"Original ballot: {r}")
|
842
|
+
print(f"Truncated ballot: {truncated_r}")
|
843
|
+
print("")
|
844
|
+
print("Original profile:")
|
845
|
+
prof.display()
|
846
|
+
prof.display_margin_graph()
|
847
|
+
vm.display(prof)
|
848
|
+
print("")
|
849
|
+
print("Modified profile:")
|
850
|
+
new_prof.display()
|
851
|
+
new_prof.display_margin_graph()
|
852
|
+
vm.display(new_prof)
|
853
|
+
|
854
|
+
return violations
|
855
|
+
|
856
|
+
later_no_harm = Axiom(
|
857
|
+
"Later No Harm",
|
858
|
+
has_violation=has_later_no_harm_violation,
|
859
|
+
find_all_violations=find_all_later_no_harm_violations
|
860
|
+
)
|
861
|
+
|
862
|
+
def truncate_ballot_from_top(ranking, num_to_keep):
|
863
|
+
"""
|
864
|
+
Truncate a ballot by removing the top candidates and keeping only the bottom num_to_keep candidates.
|
865
|
+
|
866
|
+
Args:
|
867
|
+
ranking: A Ranking object or tuple representing a ballot
|
868
|
+
num_to_keep: Number of bottom candidates to keep
|
869
|
+
|
870
|
+
Returns:
|
871
|
+
A truncated Ranking object
|
872
|
+
"""
|
873
|
+
if isinstance(ranking, tuple):
|
874
|
+
# For tuple rankings, keep only the last num_to_keep candidates
|
875
|
+
truncated = ranking[-num_to_keep:]
|
876
|
+
return Ranking({c: i+1 for i, c in enumerate(truncated)})
|
877
|
+
else:
|
878
|
+
# For Ranking objects, sort candidates by rank and keep only the bottom num_to_keep
|
879
|
+
sorted_candidates = sorted(ranking.rmap.keys(), key=lambda c: ranking.rmap[c])
|
880
|
+
truncated_rmap = {c: ranking.rmap[c] for c in sorted_candidates[-num_to_keep:]}
|
881
|
+
return Ranking(truncated_rmap)
|
882
|
+
|
883
|
+
def has_earlier_no_help_violation(prof, vm, verbose=False, coalition_size=1, uniform_coalition=True, require_resoluteness=False):
|
884
|
+
"""
|
885
|
+
Returns True if there is a ballot (or collection of ballots) such that by truncating it from the top,
|
886
|
+
a candidate who is ranked by the truncated ballot goes from winning to losing.
|
887
|
+
|
888
|
+
Viewed in reverse, this means that adding previously unranked candidates to the top of a ballot helped lower ranked candidate to win.
|
889
|
+
|
890
|
+
Args:
|
891
|
+
prof: a Profile or ProfileWithTies object.
|
892
|
+
vm (VotingMethod): A voting method to test.
|
893
|
+
verbose (bool, default=False): If a violation is found, display the violation.
|
894
|
+
coalition_size (int, default=1): Size of the coalition of voters who truncate their ballots.
|
895
|
+
uniform_coalition (bool, default=True): If True, all voters in the coalition have the same ballot.
|
896
|
+
require_resoluteness (bool, default=False): If True, only profiles with a unique winner before and after truncation are considered.
|
897
|
+
|
898
|
+
Returns:
|
899
|
+
Result of the test (bool): Returns True if there is a violation and False otherwise.
|
900
|
+
"""
|
901
|
+
# Get the current winners
|
902
|
+
winners = vm(prof)
|
903
|
+
|
904
|
+
# Convert numpy array winners to list if needed
|
905
|
+
if isinstance(winners, np.ndarray):
|
906
|
+
winners = winners.tolist()
|
907
|
+
|
908
|
+
# If require_resoluteness is True, skip profiles with multiple winners before truncation
|
909
|
+
if require_resoluteness and len(winners) > 1:
|
910
|
+
return False
|
911
|
+
|
912
|
+
# For individual voter case or uniform coalition
|
913
|
+
if uniform_coalition:
|
914
|
+
# Check each ranking type in the profile
|
915
|
+
for r in prof.ranking_types:
|
916
|
+
# Skip if there aren't enough voters with this ranking for the coalition
|
917
|
+
if isinstance(r, tuple):
|
918
|
+
if prof.rankings.count(r) < coalition_size:
|
919
|
+
continue
|
920
|
+
ranked_candidates = list(r)
|
921
|
+
r_as_ranking = Ranking({c: i+1 for i, c in enumerate(r)})
|
922
|
+
else:
|
923
|
+
if sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands) < coalition_size:
|
924
|
+
continue
|
925
|
+
ranked_candidates = list(r.cands)
|
926
|
+
r_as_ranking = r
|
927
|
+
|
928
|
+
# Try truncating at different positions (keep only last i candidates)
|
929
|
+
for i in range(1, len(ranked_candidates)):
|
930
|
+
# Create truncated ballot keeping only the last i candidates
|
931
|
+
# This is proper truncation - keeping the bottom i candidates in their original order
|
932
|
+
truncated_r = truncate_ballot_from_top(r_as_ranking, i)
|
933
|
+
|
934
|
+
# Skip if truncation didn't change anything or if truncated ballot is empty
|
935
|
+
# Ensure at least one candidate remains ranked
|
936
|
+
if len(truncated_r.cands) == 0 or truncated_r.rmap == r_as_ranking.rmap:
|
937
|
+
continue
|
938
|
+
|
939
|
+
# Get the truncated ballot as a ranking map
|
940
|
+
truncated_rmap = truncated_r.rmap
|
941
|
+
|
942
|
+
# Create a new profile with the truncated ballot(s)
|
943
|
+
modified_rankings = []
|
944
|
+
|
945
|
+
# Add all ballots except those we'll truncate
|
946
|
+
for ballot in prof.rankings:
|
947
|
+
# Skip the ballots we'll truncate
|
948
|
+
if isinstance(ballot, tuple) and ballot == r:
|
949
|
+
continue
|
950
|
+
elif isinstance(ballot, Ranking) and isinstance(r, Ranking) and ballot.cands == r.cands:
|
951
|
+
continue
|
952
|
+
|
953
|
+
# Add the ballot in the correct format
|
954
|
+
if isinstance(ballot, tuple):
|
955
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(ballot)})
|
956
|
+
else:
|
957
|
+
modified_rankings.append(ballot.rmap)
|
958
|
+
|
959
|
+
# Make sure we've skipped the right number of ballots
|
960
|
+
if isinstance(r, tuple):
|
961
|
+
original_count = prof.rankings.count(r)
|
962
|
+
else:
|
963
|
+
original_count = sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands)
|
964
|
+
|
965
|
+
# Add back any ballots we shouldn't have truncated
|
966
|
+
for _ in range(original_count - coalition_size):
|
967
|
+
modified_rankings.append(r_as_ranking.rmap)
|
968
|
+
|
969
|
+
# Add the truncated ballots
|
970
|
+
for _ in range(coalition_size):
|
971
|
+
modified_rankings.append(truncated_rmap)
|
972
|
+
|
973
|
+
# Create the new profile with ties
|
974
|
+
new_prof = ProfileWithTies(modified_rankings, candidates=prof.candidates)
|
975
|
+
if isinstance(prof, ProfileWithTies) and prof.using_extended_strict_preference:
|
976
|
+
new_prof.use_extended_strict_preference()
|
977
|
+
|
978
|
+
# Get the new winners
|
979
|
+
new_winners = vm(new_prof)
|
980
|
+
|
981
|
+
# Convert numpy array winners to list if needed
|
982
|
+
if isinstance(new_winners, np.ndarray):
|
983
|
+
new_winners = new_winners.tolist()
|
984
|
+
|
985
|
+
# If require_resoluteness is True, skip profiles with multiple winners after truncation
|
986
|
+
if require_resoluteness and len(new_winners) > 1:
|
987
|
+
continue
|
988
|
+
|
989
|
+
# Check for Earlier No Help violation: a candidate ranked in the truncated ballot
|
990
|
+
# goes from winning to losing
|
991
|
+
truncated_candidates = truncated_r.cands
|
992
|
+
|
993
|
+
# Find candidates that are ranked in the truncated ballot and went from winning to losing
|
994
|
+
winners_in_truncated = [c for c in winners if c in truncated_candidates]
|
995
|
+
new_losers_in_truncated = [c for c in winners_in_truncated if c not in new_winners]
|
996
|
+
|
997
|
+
if new_losers_in_truncated:
|
998
|
+
if verbose:
|
999
|
+
print(f"Earlier No Help violation: Candidate(s) {new_losers_in_truncated} went from winning to losing due to top truncation.")
|
1000
|
+
print("")
|
1001
|
+
print(f"Original winners: {winners}")
|
1002
|
+
print(f"New winners: {new_winners}")
|
1003
|
+
print(f"Original ballot: {r}")
|
1004
|
+
print(f"Truncated ballot: {truncated_r}")
|
1005
|
+
print("")
|
1006
|
+
print("Original profile:")
|
1007
|
+
prof.display()
|
1008
|
+
prof.display_margin_graph()
|
1009
|
+
vm.display(prof)
|
1010
|
+
print("")
|
1011
|
+
print("Modified profile:")
|
1012
|
+
new_prof.display()
|
1013
|
+
new_prof.display_margin_graph()
|
1014
|
+
vm.display(new_prof)
|
1015
|
+
return True
|
1016
|
+
|
1017
|
+
# For non-uniform coalition
|
1018
|
+
if not uniform_coalition and coalition_size > 1:
|
1019
|
+
# Get all possible combinations of ranking types
|
1020
|
+
ranking_combinations = list(combinations(prof.ranking_types, coalition_size))
|
1021
|
+
|
1022
|
+
for ranking_combo in ranking_combinations:
|
1023
|
+
# Check if we have enough of each ranking type
|
1024
|
+
valid_combo = True
|
1025
|
+
for r in ranking_combo:
|
1026
|
+
if isinstance(r, tuple):
|
1027
|
+
if prof.rankings.count(r) < ranking_combo.count(r):
|
1028
|
+
valid_combo = False
|
1029
|
+
break
|
1030
|
+
else:
|
1031
|
+
if sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands) < ranking_combo.count(r):
|
1032
|
+
valid_combo = False
|
1033
|
+
break
|
1034
|
+
|
1035
|
+
if not valid_combo:
|
1036
|
+
continue
|
1037
|
+
|
1038
|
+
# For each ranking in the combination, try truncating it
|
1039
|
+
for i, r in enumerate(ranking_combo):
|
1040
|
+
if isinstance(r, tuple):
|
1041
|
+
ranked_candidates = list(r)
|
1042
|
+
r_as_ranking = Ranking({c: i+1 for i, c in enumerate(r)})
|
1043
|
+
else:
|
1044
|
+
ranked_candidates = list(r.cands)
|
1045
|
+
r_as_ranking = r
|
1046
|
+
|
1047
|
+
# Try truncating at different positions
|
1048
|
+
for j in range(1, len(ranked_candidates)):
|
1049
|
+
# Create truncated ballot keeping only the last j candidates
|
1050
|
+
# This is proper truncation - keeping the bottom j candidates in their original order
|
1051
|
+
truncated_r = truncate_ballot_from_top(r_as_ranking, j)
|
1052
|
+
|
1053
|
+
# Skip if truncation didn't change anything or if truncated ballot is empty
|
1054
|
+
if len(truncated_r.cands) == 0 or truncated_r.rmap == r_as_ranking.rmap:
|
1055
|
+
continue
|
1056
|
+
|
1057
|
+
# Get the truncated ballot as a ranking map
|
1058
|
+
truncated_rmap = truncated_r.rmap
|
1059
|
+
|
1060
|
+
# Create a new profile with the truncated ballot(s)
|
1061
|
+
modified_rankings = []
|
1062
|
+
|
1063
|
+
# Add all ballots except those in the coalition
|
1064
|
+
for ballot in prof.rankings:
|
1065
|
+
skip = False
|
1066
|
+
for coalition_r in ranking_combo:
|
1067
|
+
if (isinstance(ballot, tuple) and ballot == coalition_r) or (isinstance(ballot, Ranking) and isinstance(coalition_r, Ranking) and ballot.cands == coalition_r.cands):
|
1068
|
+
skip = True
|
1069
|
+
break
|
1070
|
+
|
1071
|
+
if skip:
|
1072
|
+
continue
|
1073
|
+
|
1074
|
+
# Add the ballot in the correct format
|
1075
|
+
if isinstance(ballot, tuple):
|
1076
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(ballot)})
|
1077
|
+
else:
|
1078
|
+
modified_rankings.append(ballot.rmap)
|
1079
|
+
|
1080
|
+
# Add back the coalition ballots, with the i-th one truncated
|
1081
|
+
for k, coalition_r in enumerate(ranking_combo):
|
1082
|
+
if k == i:
|
1083
|
+
modified_rankings.append(truncated_rmap)
|
1084
|
+
else:
|
1085
|
+
if isinstance(coalition_r, tuple):
|
1086
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(coalition_r)})
|
1087
|
+
else:
|
1088
|
+
modified_rankings.append(coalition_r.rmap)
|
1089
|
+
|
1090
|
+
# Create the new profile with ties
|
1091
|
+
new_prof = ProfileWithTies(modified_rankings, candidates=prof.candidates)
|
1092
|
+
if isinstance(prof, ProfileWithTies) and prof.using_extended_strict_preference:
|
1093
|
+
new_prof.use_extended_strict_preference()
|
1094
|
+
|
1095
|
+
# Get the new winners
|
1096
|
+
new_winners = vm(new_prof)
|
1097
|
+
|
1098
|
+
# Convert numpy array winners to list if needed
|
1099
|
+
if isinstance(new_winners, np.ndarray):
|
1100
|
+
new_winners = new_winners.tolist()
|
1101
|
+
|
1102
|
+
# If require_resoluteness is True, skip profiles with multiple winners after truncation
|
1103
|
+
if require_resoluteness and len(new_winners) > 1:
|
1104
|
+
continue
|
1105
|
+
|
1106
|
+
# Check for Earlier No Help violation: a candidate ranked in the truncated ballot
|
1107
|
+
# goes from winning to losing
|
1108
|
+
truncated_candidates = truncated_r.cands
|
1109
|
+
|
1110
|
+
# Find candidates that are ranked in the truncated ballot and went from winning to losing
|
1111
|
+
winners_in_truncated = [c for c in winners if c in truncated_candidates]
|
1112
|
+
new_losers_in_truncated = [c for c in winners_in_truncated if c not in new_winners]
|
1113
|
+
|
1114
|
+
if new_losers_in_truncated:
|
1115
|
+
if verbose:
|
1116
|
+
print(f"Earlier No Help violation: Candidate(s) {new_losers_in_truncated} went from winning to losing due to top truncation.")
|
1117
|
+
print("")
|
1118
|
+
print(f"Original winners: {winners}")
|
1119
|
+
print(f"New winners: {new_winners}")
|
1120
|
+
print(f"Original ballot: {r}")
|
1121
|
+
print(f"Truncated ballot: {truncated_r}")
|
1122
|
+
print("")
|
1123
|
+
print("Original profile:")
|
1124
|
+
prof.display()
|
1125
|
+
prof.display_margin_graph()
|
1126
|
+
vm.display(prof)
|
1127
|
+
print("")
|
1128
|
+
print("Modified profile:")
|
1129
|
+
new_prof.display()
|
1130
|
+
new_prof.display_margin_graph()
|
1131
|
+
vm.display(new_prof)
|
1132
|
+
return True
|
1133
|
+
|
1134
|
+
return False
|
1135
|
+
|
1136
|
+
def find_all_earlier_no_help_violations(prof, vm, verbose=False, coalition_size=1, uniform_coalition=True, require_resoluteness=False):
|
1137
|
+
"""
|
1138
|
+
Returns a list of tuples (original_ballot, truncated_ballot, original_winners, new_winners, new_losers_in_truncated) such that top-truncating the original_ballot to the truncated_ballot causes a candidate ranked in the truncated ballot to go from winning to losing.
|
1139
|
+
|
1140
|
+
Args:
|
1141
|
+
prof: a Profile or ProfileWithTies object.
|
1142
|
+
vm (VotingMethod): A voting method to test.
|
1143
|
+
verbose (bool, default=False): If a violation is found, display the violation.
|
1144
|
+
coalition_size (int, default=1): Size of the coalition of voters who truncate their ballots.
|
1145
|
+
uniform_coalition (bool, default=True): If True, all voters in the coalition have the same ballot.
|
1146
|
+
require_resoluteness (bool, default=False): If True, only profiles with a unique winner before and after truncation are considered.
|
1147
|
+
|
1148
|
+
Returns:
|
1149
|
+
A list of tuples (original_ballot, truncated_ballot, original_winners, new_winners, new_losers_in_truncated)
|
1150
|
+
witnessing violations of Earlier No Help. The new_losers_in_truncated element contains the candidates that
|
1151
|
+
specifically caused the violation by going from winning to losing while being ranked in the truncated ballot.
|
1152
|
+
"""
|
1153
|
+
violations = []
|
1154
|
+
|
1155
|
+
# Get the current winners
|
1156
|
+
winners = vm(prof)
|
1157
|
+
|
1158
|
+
# Convert numpy array winners to list if needed
|
1159
|
+
if isinstance(winners, np.ndarray):
|
1160
|
+
winners = winners.tolist()
|
1161
|
+
|
1162
|
+
# If require_resoluteness is True, skip profiles with multiple winners before truncation
|
1163
|
+
if require_resoluteness and len(winners) > 1:
|
1164
|
+
return violations
|
1165
|
+
|
1166
|
+
# For individual voter case or uniform coalition
|
1167
|
+
if uniform_coalition:
|
1168
|
+
# Check each ranking type in the profile
|
1169
|
+
for r in prof.ranking_types:
|
1170
|
+
# Skip if there aren't enough voters with this ranking for the coalition
|
1171
|
+
if isinstance(r, tuple):
|
1172
|
+
if prof.rankings.count(r) < coalition_size:
|
1173
|
+
continue
|
1174
|
+
ranked_candidates = list(r)
|
1175
|
+
r_as_ranking = Ranking({c: i+1 for i, c in enumerate(r)})
|
1176
|
+
else:
|
1177
|
+
if sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands) < coalition_size:
|
1178
|
+
continue
|
1179
|
+
ranked_candidates = list(r.cands)
|
1180
|
+
r_as_ranking = r
|
1181
|
+
|
1182
|
+
# Try truncating at different positions (keep only last i candidates)
|
1183
|
+
for i in range(1, len(ranked_candidates)):
|
1184
|
+
# Create truncated ballot keeping only the last i candidates
|
1185
|
+
# This is proper truncation - keeping the bottom i candidates in their original order
|
1186
|
+
truncated_r = truncate_ballot_from_top(r_as_ranking, i)
|
1187
|
+
|
1188
|
+
# Skip if truncation didn't change anything or if truncated ballot is empty
|
1189
|
+
# Ensure at least one candidate remains ranked
|
1190
|
+
if len(truncated_r.cands) == 0 or truncated_r.rmap == r_as_ranking.rmap:
|
1191
|
+
continue
|
1192
|
+
|
1193
|
+
# Get the truncated ballot as a ranking map
|
1194
|
+
truncated_rmap = truncated_r.rmap
|
1195
|
+
|
1196
|
+
# Create a new profile with the truncated ballot(s)
|
1197
|
+
modified_rankings = []
|
1198
|
+
|
1199
|
+
# Add all ballots except those we'll truncate
|
1200
|
+
for ballot in prof.rankings:
|
1201
|
+
# Skip the ballots we'll truncate
|
1202
|
+
if isinstance(ballot, tuple) and ballot == r:
|
1203
|
+
continue
|
1204
|
+
elif isinstance(ballot, Ranking) and isinstance(r, Ranking) and ballot.cands == r.cands:
|
1205
|
+
continue
|
1206
|
+
|
1207
|
+
# Add the ballot in the correct format
|
1208
|
+
if isinstance(ballot, tuple):
|
1209
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(ballot)})
|
1210
|
+
else:
|
1211
|
+
modified_rankings.append(ballot.rmap)
|
1212
|
+
|
1213
|
+
# Make sure we've skipped the right number of ballots
|
1214
|
+
if isinstance(r, tuple):
|
1215
|
+
original_count = prof.rankings.count(r)
|
1216
|
+
else:
|
1217
|
+
original_count = sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands)
|
1218
|
+
|
1219
|
+
# Add back any ballots we shouldn't have truncated
|
1220
|
+
for _ in range(original_count - coalition_size):
|
1221
|
+
modified_rankings.append(r_as_ranking.rmap)
|
1222
|
+
|
1223
|
+
# Add the truncated ballots
|
1224
|
+
for _ in range(coalition_size):
|
1225
|
+
modified_rankings.append(truncated_rmap)
|
1226
|
+
|
1227
|
+
# Create the new profile with ties
|
1228
|
+
new_prof = ProfileWithTies(modified_rankings, candidates=prof.candidates)
|
1229
|
+
if isinstance(prof, ProfileWithTies) and prof.using_extended_strict_preference:
|
1230
|
+
new_prof.use_extended_strict_preference()
|
1231
|
+
|
1232
|
+
# Get the new winners
|
1233
|
+
new_winners = vm(new_prof)
|
1234
|
+
|
1235
|
+
# Convert numpy array winners to list if needed
|
1236
|
+
if isinstance(new_winners, np.ndarray):
|
1237
|
+
new_winners = new_winners.tolist()
|
1238
|
+
|
1239
|
+
# If require_resoluteness is True, skip profiles with multiple winners after truncation
|
1240
|
+
if require_resoluteness and len(new_winners) > 1:
|
1241
|
+
continue
|
1242
|
+
|
1243
|
+
# Check for Earlier No Help violation: a candidate ranked in the truncated ballot
|
1244
|
+
# goes from winning to losing
|
1245
|
+
truncated_candidates = truncated_r.cands
|
1246
|
+
|
1247
|
+
# Find candidates that are ranked in the truncated ballot and went from winning to losing
|
1248
|
+
winners_in_truncated = [c for c in winners if c in truncated_candidates]
|
1249
|
+
new_losers_in_truncated = [c for c in winners_in_truncated if c not in new_winners]
|
1250
|
+
|
1251
|
+
if new_losers_in_truncated:
|
1252
|
+
violations.append((r, truncated_r, winners, new_winners, new_losers_in_truncated))
|
1253
|
+
if verbose:
|
1254
|
+
print(f"Earlier No Help violation: Candidate(s) {new_losers_in_truncated} went from winning to losing due to top truncation.")
|
1255
|
+
print("")
|
1256
|
+
print(f"Original winners: {winners}")
|
1257
|
+
print(f"New winners: {new_winners}")
|
1258
|
+
print(f"Original ballot: {r}")
|
1259
|
+
print(f"Truncated ballot: {truncated_r}")
|
1260
|
+
print("")
|
1261
|
+
print("Original profile:")
|
1262
|
+
prof.display()
|
1263
|
+
prof.display_margin_graph()
|
1264
|
+
vm.display(prof)
|
1265
|
+
print("")
|
1266
|
+
print("Modified profile:")
|
1267
|
+
new_prof.display()
|
1268
|
+
new_prof.display_margin_graph()
|
1269
|
+
vm.display(new_prof)
|
1270
|
+
|
1271
|
+
# For non-uniform coalition
|
1272
|
+
if not uniform_coalition and coalition_size > 1:
|
1273
|
+
# Get all possible combinations of ranking types
|
1274
|
+
ranking_combinations = list(combinations(prof.ranking_types, coalition_size))
|
1275
|
+
|
1276
|
+
for ranking_combo in ranking_combinations:
|
1277
|
+
# Check if we have enough of each ranking type
|
1278
|
+
valid_combo = True
|
1279
|
+
for r in ranking_combo:
|
1280
|
+
if isinstance(r, tuple):
|
1281
|
+
if prof.rankings.count(r) < ranking_combo.count(r):
|
1282
|
+
valid_combo = False
|
1283
|
+
break
|
1284
|
+
else:
|
1285
|
+
if sum(1 for ballot in prof.rankings if isinstance(ballot, Ranking) and ballot.cands == r.cands) < ranking_combo.count(r):
|
1286
|
+
valid_combo = False
|
1287
|
+
break
|
1288
|
+
|
1289
|
+
if not valid_combo:
|
1290
|
+
continue
|
1291
|
+
|
1292
|
+
# For each ranking in the combination, try truncating it
|
1293
|
+
for i, r in enumerate(ranking_combo):
|
1294
|
+
if isinstance(r, tuple):
|
1295
|
+
ranked_candidates = list(r)
|
1296
|
+
r_as_ranking = Ranking({c: i+1 for i, c in enumerate(r)})
|
1297
|
+
else:
|
1298
|
+
ranked_candidates = list(r.cands)
|
1299
|
+
r_as_ranking = r
|
1300
|
+
|
1301
|
+
# Try truncating at different positions
|
1302
|
+
for j in range(1, len(ranked_candidates)):
|
1303
|
+
# Create truncated ballot keeping only the last j candidates
|
1304
|
+
# This is proper truncation - keeping the bottom j candidates in their original order
|
1305
|
+
truncated_r = truncate_ballot_from_top(r_as_ranking, j)
|
1306
|
+
|
1307
|
+
# Skip if truncation didn't change anything or if truncated ballot is empty
|
1308
|
+
if len(truncated_r.cands) == 0 or truncated_r.rmap == r_as_ranking.rmap:
|
1309
|
+
continue
|
1310
|
+
|
1311
|
+
# Get the truncated ballot as a ranking map
|
1312
|
+
truncated_rmap = truncated_r.rmap
|
1313
|
+
|
1314
|
+
# Create a new profile with the truncated ballot(s)
|
1315
|
+
modified_rankings = []
|
1316
|
+
|
1317
|
+
# Add all ballots except those in the coalition
|
1318
|
+
for ballot in prof.rankings:
|
1319
|
+
skip = False
|
1320
|
+
for coalition_r in ranking_combo:
|
1321
|
+
if (isinstance(ballot, tuple) and ballot == coalition_r) or (isinstance(ballot, Ranking) and isinstance(coalition_r, Ranking) and ballot.cands == coalition_r.cands):
|
1322
|
+
skip = True
|
1323
|
+
break
|
1324
|
+
|
1325
|
+
if skip:
|
1326
|
+
continue
|
1327
|
+
|
1328
|
+
# Add the ballot in the correct format
|
1329
|
+
if isinstance(ballot, tuple):
|
1330
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(ballot)})
|
1331
|
+
else:
|
1332
|
+
modified_rankings.append(ballot.rmap)
|
1333
|
+
|
1334
|
+
# Add back the coalition ballots, with the i-th one truncated
|
1335
|
+
for k, coalition_r in enumerate(ranking_combo):
|
1336
|
+
if k == i:
|
1337
|
+
modified_rankings.append(truncated_rmap)
|
1338
|
+
else:
|
1339
|
+
if isinstance(coalition_r, tuple):
|
1340
|
+
modified_rankings.append({c: i+1 for i, c in enumerate(coalition_r)})
|
1341
|
+
else:
|
1342
|
+
modified_rankings.append(coalition_r.rmap)
|
1343
|
+
|
1344
|
+
# Create the new profile with ties
|
1345
|
+
new_prof = ProfileWithTies(modified_rankings, candidates=prof.candidates)
|
1346
|
+
if isinstance(prof, ProfileWithTies) and prof.using_extended_strict_preference:
|
1347
|
+
new_prof.use_extended_strict_preference()
|
1348
|
+
|
1349
|
+
# Get the new winners
|
1350
|
+
new_winners = vm(new_prof)
|
1351
|
+
|
1352
|
+
# Convert numpy array winners to list if needed
|
1353
|
+
if isinstance(new_winners, np.ndarray):
|
1354
|
+
new_winners = new_winners.tolist()
|
1355
|
+
|
1356
|
+
# If require_resoluteness is True, skip profiles with multiple winners after truncation
|
1357
|
+
if require_resoluteness and len(new_winners) > 1:
|
1358
|
+
continue
|
1359
|
+
|
1360
|
+
# Check for Earlier No Help violation: a candidate ranked in the truncated ballot
|
1361
|
+
# goes from winning to losing
|
1362
|
+
truncated_candidates = truncated_r.cands
|
1363
|
+
|
1364
|
+
# Find candidates that are ranked in the truncated ballot and went from winning to losing
|
1365
|
+
winners_in_truncated = [c for c in winners if c in truncated_candidates]
|
1366
|
+
new_losers_in_truncated = [c for c in winners_in_truncated if c not in new_winners]
|
1367
|
+
|
1368
|
+
if new_losers_in_truncated:
|
1369
|
+
violations.append((r, truncated_r, winners, new_winners, new_losers_in_truncated))
|
1370
|
+
if verbose:
|
1371
|
+
print(f"Earlier No Help violation: Candidate(s) {new_losers_in_truncated} went from winning to losing due to top truncation.")
|
1372
|
+
print("")
|
1373
|
+
print(f"Original winners: {winners}")
|
1374
|
+
print(f"New winners: {new_winners}")
|
1375
|
+
print(f"Original ballot: {r}")
|
1376
|
+
print(f"Truncated ballot: {truncated_r}")
|
1377
|
+
print("")
|
1378
|
+
print("Original profile:")
|
1379
|
+
prof.display()
|
1380
|
+
prof.display_margin_graph()
|
1381
|
+
vm.display(prof)
|
1382
|
+
print("")
|
1383
|
+
print("Modified profile:")
|
1384
|
+
new_prof.display()
|
1385
|
+
new_prof.display_margin_graph()
|
1386
|
+
vm.display(new_prof)
|
1387
|
+
|
1388
|
+
return violations
|
1389
|
+
|
1390
|
+
earlier_no_help = Axiom(
|
1391
|
+
"Earlier No Help",
|
1392
|
+
has_violation=has_earlier_no_help_violation,
|
1393
|
+
find_all_violations=find_all_earlier_no_help_violations
|
1394
|
+
)
|