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.
Files changed (92) hide show
  1. pref_voting/__init__.py +1 -0
  2. pref_voting/analysis.py +496 -0
  3. pref_voting/axiom.py +38 -0
  4. pref_voting/axiom_helpers.py +129 -0
  5. pref_voting/axioms.py +10 -0
  6. pref_voting/c1_methods.py +963 -0
  7. pref_voting/combined_methods.py +514 -0
  8. pref_voting/create_methods.py +128 -0
  9. pref_voting/data/examples/condorcet_winner/minimal_Anti-Plurality.soc +16 -0
  10. pref_voting/data/examples/condorcet_winner/minimal_Borda.soc +17 -0
  11. pref_voting/data/examples/condorcet_winner/minimal_Bracket_Voting.soc +20 -0
  12. pref_voting/data/examples/condorcet_winner/minimal_Bucklin.soc +19 -0
  13. pref_voting/data/examples/condorcet_winner/minimal_Coombs.soc +20 -0
  14. pref_voting/data/examples/condorcet_winner/minimal_Coombs_PUT.soc +20 -0
  15. pref_voting/data/examples/condorcet_winner/minimal_Coombs_TB.soc +20 -0
  16. pref_voting/data/examples/condorcet_winner/minimal_Dowdall.soc +19 -0
  17. pref_voting/data/examples/condorcet_winner/minimal_Instant_Runoff.soc +18 -0
  18. pref_voting/data/examples/condorcet_winner/minimal_Instant_Runoff_PUT.soc +18 -0
  19. pref_voting/data/examples/condorcet_winner/minimal_Instant_Runoff_TB.soc +18 -0
  20. pref_voting/data/examples/condorcet_winner/minimal_Iterated_Removal_Condorcet_Loser.soc +17 -0
  21. pref_voting/data/examples/condorcet_winner/minimal_Pareto.soc +17 -0
  22. pref_voting/data/examples/condorcet_winner/minimal_Plurality.soc +18 -0
  23. pref_voting/data/examples/condorcet_winner/minimal_PluralityWRunoff_PUT.soc +18 -0
  24. pref_voting/data/examples/condorcet_winner/minimal_Positive-Negative_Voting.soc +17 -0
  25. pref_voting/data/examples/condorcet_winner/minimal_Simplified_Bucklin.soc +18 -0
  26. pref_voting/data/examples/condorcet_winner/minimal_Superior_Voting.soc +19 -0
  27. pref_voting/data/examples/condorcet_winner/minimal_Weighted_Bucklin.soc +19 -0
  28. pref_voting/data/examples/condorcet_winner/minimal_resolute_Anti-Plurality.soc +17 -0
  29. pref_voting/data/examples/condorcet_winner/minimal_resolute_Borda.soc +17 -0
  30. pref_voting/data/examples/condorcet_winner/minimal_resolute_Bracket_Voting.soc +20 -0
  31. pref_voting/data/examples/condorcet_winner/minimal_resolute_Bucklin.soc +19 -0
  32. pref_voting/data/examples/condorcet_winner/minimal_resolute_Coombs.soc +21 -0
  33. pref_voting/data/examples/condorcet_winner/minimal_resolute_Coombs_PUT.soc +21 -0
  34. pref_voting/data/examples/condorcet_winner/minimal_resolute_Coombs_TB.soc +20 -0
  35. pref_voting/data/examples/condorcet_winner/minimal_resolute_Dowdall.soc +18 -0
  36. pref_voting/data/examples/condorcet_winner/minimal_resolute_Instant_Runoff.soc +18 -0
  37. pref_voting/data/examples/condorcet_winner/minimal_resolute_Instant_Runoff_PUT.soc +18 -0
  38. pref_voting/data/examples/condorcet_winner/minimal_resolute_Instant_Runoff_TB.soc +18 -0
  39. pref_voting/data/examples/condorcet_winner/minimal_resolute_Plurality.soc +18 -0
  40. pref_voting/data/examples/condorcet_winner/minimal_resolute_PluralityWRunoff_PUT.soc +18 -0
  41. pref_voting/data/examples/condorcet_winner/minimal_resolute_Positive-Negative_Voting.soc +17 -0
  42. pref_voting/data/examples/condorcet_winner/minimal_resolute_Simplified_Bucklin.soc +20 -0
  43. pref_voting/data/examples/condorcet_winner/minimal_resolute_Weighted_Bucklin.soc +19 -0
  44. pref_voting/data/voting_methods_properties.json +414 -0
  45. pref_voting/data/voting_methods_properties.json.lock +0 -0
  46. pref_voting/dominance_axioms.py +387 -0
  47. pref_voting/generate_profiles.py +801 -0
  48. pref_voting/generate_spatial_profiles.py +198 -0
  49. pref_voting/generate_utility_profiles.py +160 -0
  50. pref_voting/generate_weighted_majority_graphs.py +506 -0
  51. pref_voting/grade_methods.py +184 -0
  52. pref_voting/grade_profiles.py +357 -0
  53. pref_voting/helper.py +370 -0
  54. pref_voting/invariance_axioms.py +671 -0
  55. pref_voting/io/__init__.py +0 -0
  56. pref_voting/io/readers.py +432 -0
  57. pref_voting/io/writers.py +256 -0
  58. pref_voting/iterative_methods.py +2425 -0
  59. pref_voting/maj_graph_ex1.png +0 -0
  60. pref_voting/mappings.py +577 -0
  61. pref_voting/margin_based_methods.py +2345 -0
  62. pref_voting/monotonicity_axioms.py +872 -0
  63. pref_voting/num_evaluation_method.py +77 -0
  64. pref_voting/other_axioms.py +161 -0
  65. pref_voting/other_methods.py +939 -0
  66. pref_voting/pairwise_profiles.py +547 -0
  67. pref_voting/prob_voting_method.py +105 -0
  68. pref_voting/probabilistic_methods.py +287 -0
  69. pref_voting/profiles.py +856 -0
  70. pref_voting/profiles_with_ties.py +1069 -0
  71. pref_voting/rankings.py +466 -0
  72. pref_voting/scoring_methods.py +481 -0
  73. pref_voting/social_welfare_function.py +59 -0
  74. pref_voting/social_welfare_functions.py +7 -0
  75. pref_voting/spatial_profiles.py +448 -0
  76. pref_voting/stochastic_methods.py +99 -0
  77. pref_voting/strategic_axioms.py +1394 -0
  78. pref_voting/swf_axioms.py +173 -0
  79. pref_voting/utility_functions.py +102 -0
  80. pref_voting/utility_methods.py +178 -0
  81. pref_voting/utility_profiles.py +333 -0
  82. pref_voting/variable_candidate_axioms.py +640 -0
  83. pref_voting/variable_voter_axioms.py +3747 -0
  84. pref_voting/voting_method.py +355 -0
  85. pref_voting/voting_method_properties.py +92 -0
  86. pref_voting/voting_methods.py +8 -0
  87. pref_voting/voting_methods_registry.py +136 -0
  88. pref_voting/weighted_majority_graphs.py +1539 -0
  89. pref_voting-1.16.31.dist-info/METADATA +208 -0
  90. pref_voting-1.16.31.dist-info/RECORD +92 -0
  91. pref_voting-1.16.31.dist-info/WHEEL +4 -0
  92. 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
+ )