pref_voting 1.17.1__py3-none-any.whl → 1.17.3__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.
@@ -0,0 +1,1575 @@
1
+ '''
2
+ File: proportional_methods.py
3
+ Author: Wes Holliday (wesholliday@berkeley.edu) and Eric Pacuit (epacuit@umd.edu)
4
+ Date: September 29, 2025
5
+
6
+ Implementations of voting methods for proportional representation.
7
+ '''
8
+ import os
9
+ import math
10
+ import itertools
11
+ import collections
12
+ import random
13
+
14
+ from pref_voting.weighted_majority_graphs import MarginGraph
15
+ from pref_voting.margin_based_methods import minimax
16
+ from pref_voting.profiles_with_ties import ProfileWithTies
17
+ from pref_voting.voting_method import vm
18
+ from pref_voting.voting_method_properties import ElectionTypes
19
+ from pref_voting.profiles import Profile
20
+
21
+ EPS = 1e-12
22
+ TRACE = bool(int(os.environ.get("STV_TRACE", "0") or "0"))
23
+
24
+ def _t(msg):
25
+ if TRACE:
26
+ print(msg)
27
+
28
+ # ---------- Piece model ----------
29
+
30
+ class RankingPiece:
31
+ """
32
+ A piece represents a fractional portion of a voter's ballot weight allocated to a specific candidate.
33
+
34
+ In STV (Single Transferable Vote), when candidates receive surplus votes above the quota or when
35
+ candidates are eliminated, ballot weights must be transferred to other candidates according to
36
+ voter preferences. Rather than transferring whole ballots, the system creates "pieces", which are fractional
37
+ portions of ballot weight that can be allocated independently.
38
+
39
+ For example, if a candidate receives 120 votes but only needs 100 to meet quota, the surplus 20
40
+ votes are transferred as pieces with reduced weight (20/120 = 1/6 of original weight) to the
41
+ next preferences on those ballots.
42
+
43
+ Each piece tracks:
44
+ - ranking: The original voter's preference ranking (Ranking object)
45
+ - weight: The fractional weight of this piece (0.0 to 1.0)
46
+ - current_rank: The preference level this piece is currently at
47
+ - cand: The candidate this piece is currently allocated to
48
+ """
49
+ __slots__ = ("ranking", "weight", "current_rank", "cand", "arrived_value")
50
+ def __init__(self, ranking, weight, current_rank, cand, arrived_value=None):
51
+ self.ranking = ranking
52
+ self.weight = weight
53
+ self.current_rank = current_rank
54
+ self.cand = cand
55
+ # 'arrived_value' is the value credited to this candidate when this piece ARRIVED.
56
+ self.arrived_value = weight if arrived_value is None else arrived_value
57
+ def clone_to(self, cand, new_rank, weight):
58
+ # When a piece moves to a new candidate, its arrival value at that candidate is the amount moved.
59
+ return RankingPiece(self.ranking, weight, new_rank, cand, arrived_value=weight)
60
+
61
+ class ParcelIndex:
62
+ """
63
+ Track which pieces belong to which parcel for last-parcel transfer rules.
64
+
65
+ In some STV variants (like Australian Senate rules), surplus transfers use only the
66
+ "last parcel" of votes received by a candidate, rather than all votes. This class
67
+ tracks which pieces arrived in which order so the last parcel can be identified.
68
+
69
+ A "parcel" is a group of pieces that arrived together during a single transfer operation.
70
+ """
71
+ def __init__(self):
72
+ self._last = collections.defaultdict(list)
73
+ def start_new_parcel(self, cand):
74
+ self._last[cand] = []
75
+ def note_arrival(self, cand, piece_idx):
76
+ self._last[cand].append(piece_idx)
77
+ def last_parcel(self, cand):
78
+ return self._last.get(cand, [])
79
+ def clear_parcel(self, cand):
80
+ self._last[cand] = []
81
+ def remap_indices(self, mapping):
82
+ if not mapping:
83
+ return
84
+ for cand, idxs in list(self._last.items()):
85
+ remapped = []
86
+ for idx in idxs:
87
+ if idx in mapping:
88
+ remapped.append(mapping[idx])
89
+ self._last[cand] = remapped
90
+
91
+ def _initial_pieces_from_profile(profile, recipients, parcels):
92
+ """
93
+ Create initial ranking pieces from ProfileWithTies.
94
+
95
+ Converts a ProfileWithTies into the piece-based representation used by STV algorithms.
96
+ Each voter's ranking becomes one or more pieces allocated to their most preferred
97
+ available candidates. If multiple candidates are tied at the top rank, the ballot
98
+ weight is split equally among them (see approval_stv for a different approach).
99
+
100
+ Args:
101
+ profile: ProfileWithTies object containing voter rankings
102
+ recipients: Set of candidates eligible to receive pieces
103
+ parcels: ParcelIndex to track piece arrival order
104
+
105
+ Returns:
106
+ List of RankingPiece objects representing the initial allocation
107
+ """
108
+ pieces = []
109
+ rankings, rcounts = profile.rankings_counts
110
+ for ranking, count in zip(rankings, rcounts):
111
+ if count <= 0:
112
+ continue
113
+ rmap = ranking.rmap
114
+ first_rank = None
115
+ first_cands = []
116
+ for c, r in rmap.items():
117
+ if r is not None and c in recipients:
118
+ if first_rank is None or r < first_rank:
119
+ first_rank = r; first_cands = [c]
120
+ elif r == first_rank:
121
+ first_cands.append(c)
122
+ if first_cands:
123
+ share = float(count) / len(first_cands)
124
+ for c in first_cands:
125
+ p = RankingPiece(ranking, share, first_rank, c, arrived_value=share)
126
+ parcels.note_arrival(c, len(pieces))
127
+ pieces.append(p)
128
+ return pieces
129
+
130
+ def _tally_from_pieces(pieces, restrict_to=None):
131
+ """
132
+ Tally the total weight allocated to each candidate from a collection of pieces.
133
+
134
+ Args:
135
+ pieces: List of RankingPiece objects
136
+ restrict_to: Optional set of candidates to include in tally
137
+
138
+ Returns:
139
+ Dictionary mapping candidates to their total allocated weight
140
+ """
141
+ t = collections.defaultdict(float)
142
+ for p in pieces:
143
+ if p.weight <= EPS:
144
+ continue
145
+ if restrict_to is None or p.cand in restrict_to:
146
+ t[p.cand] += p.weight
147
+ return t
148
+
149
+ def _next_prefs_from_ranking(ranking, recipients, current_rank):
150
+ """Find next preferences in a ranking after current_rank that are in recipients."""
151
+ rmap = ranking.rmap
152
+ next_ranks = [r for c, r in rmap.items() if r is not None and r > current_rank and c in recipients]
153
+ if not next_ranks:
154
+ return [], -1
155
+ next_rank = min(next_ranks)
156
+ next_cands = [c for c, r in rmap.items() if r == next_rank and c in recipients]
157
+ return next_cands, next_rank
158
+
159
+ def _move_piece_forward(piece, recipients):
160
+ """Move a ranking piece forward to next preferences."""
161
+ nxt, new_rank = _next_prefs_from_ranking(piece.ranking, recipients, piece.current_rank)
162
+ if not nxt:
163
+ return []
164
+ share = 1.0 / float(len(nxt))
165
+ return [(c, share, new_rank) for c in nxt]
166
+
167
+ # ---------- Surplus & elimination ----------
168
+
169
+ def _transfer_surplus_inclusive(pieces, elect, quota, recipients, parcels,
170
+ drain_all=True, last_parcel_only=False, ers_rounding=False):
171
+ """
172
+ Inclusive Gregory: drain a common fraction from the winner's pile and move the drained
173
+ mass to next available continuing preferences.
174
+ • drain_all=True: drain the fraction from *all* ballots in the pile. Portions with
175
+ no next preference exhaust. (Used for WIG and last‑parcel.)
176
+ • drain_all=False: “compensation” (ERS/NB) – only donors that have a next
177
+ preference are drained; the fraction is increased so the surplus is fully removed.
178
+ • last_parcel_only=True: only drain pieces in the most recent parcel (Senatorial).
179
+ Returns True if any weight moved.
180
+ """
181
+ tall = _tally_from_pieces(pieces, restrict_to=(set(recipients) | {elect}))
182
+ surplus = tall.get(elect, 0.0) - quota
183
+ if surplus <= EPS:
184
+ return False
185
+
186
+ if last_parcel_only:
187
+ donor_idxs = list(parcels.last_parcel(elect))
188
+ else:
189
+ donor_idxs = [i for i, p in enumerate(pieces) if p.cand == elect and p.weight > EPS]
190
+ if not donor_idxs:
191
+ return False
192
+
193
+ if drain_all:
194
+ total_weight = sum(pieces[i].weight for i in donor_idxs)
195
+ if total_weight <= EPS:
196
+ return False
197
+ frac = min(1.0, surplus / total_weight)
198
+ any_moved = False
199
+ opened = set() # ensure a *new* parcel is opened for each recipient
200
+ for i in donor_idxs:
201
+ p = pieces[i]
202
+ drain = p.weight * frac
203
+ if ers_rounding:
204
+ # ERS practice: round transfer values down to hundredth
205
+ drain = math.floor(drain * 100.0) / 100.0
206
+ if drain <= EPS:
207
+ continue
208
+ forwards = _move_piece_forward(p, recipients)
209
+ if forwards:
210
+ share = drain / float(len(forwards))
211
+ for nxt_c, _, new_idx in forwards:
212
+ if nxt_c not in opened:
213
+ parcels.start_new_parcel(nxt_c)
214
+ opened.add(nxt_c)
215
+ pieces.append(p.clone_to(nxt_c, new_idx, share))
216
+ parcels.note_arrival(nxt_c, len(pieces)-1)
217
+ # drain even if no forward (exhaust)
218
+ p.weight -= drain
219
+ any_moved = True
220
+ if last_parcel_only:
221
+ parcels.clear_parcel(elect)
222
+ return any_moved
223
+
224
+ # compensation (ERS/NB)
225
+ donors = []
226
+ nxt_cache = {}
227
+ for i in donor_idxs:
228
+ forwards = _move_piece_forward(pieces[i], recipients)
229
+ if forwards:
230
+ donors.append(i)
231
+ nxt_cache[i] = forwards
232
+ total_transferable = sum(pieces[i].weight for i in donors)
233
+ if total_transferable <= EPS:
234
+ return False
235
+ frac = min(1.0, surplus / total_transferable)
236
+ any_moved = False
237
+ opened = set() # also open new parcels under compensation variant
238
+ for i in donors:
239
+ p = pieces[i]
240
+ drain = p.weight * frac
241
+ if ers_rounding:
242
+ # ERS practice: round transfer values down to hundredth
243
+ drain = math.floor(drain * 100.0) / 100.0
244
+ if drain <= EPS:
245
+ continue
246
+ forwards = nxt_cache[i]
247
+ share = drain / float(len(forwards))
248
+ for nxt_c, _, new_idx in forwards:
249
+ if nxt_c not in opened:
250
+ parcels.start_new_parcel(nxt_c)
251
+ opened.add(nxt_c)
252
+ pieces.append(p.clone_to(nxt_c, new_idx, share))
253
+ parcels.note_arrival(nxt_c, len(pieces)-1)
254
+ p.weight -= drain
255
+ any_moved = True
256
+ if last_parcel_only:
257
+ parcels.clear_parcel(elect)
258
+ return any_moved
259
+
260
+ def _transfer_surplus_scottish(pieces, elect, quota, recipients, parcels, *, decimals=5):
261
+ """
262
+ Scottish STV surplus transfer (SSI 2007/42):
263
+ For each ballot piece currently credited to the elected candidate, compute
264
+ transfer = truncate_to_decimals((surplus * piece_value_when_received) / total, decimals)
265
+ and push that amount to the next available preference(s) among *continuing* candidates.
266
+ Portions with no next available preference do not transfer (Rule 48(1)(b)).
267
+ (I.e., one truncation of A/B to `decimals`; A = surplus * piece_value_when_received,
268
+ B = total currently credited to the winner.) Largest‑surplus‑first is handled by caller (Rule 49).
269
+
270
+ Returns: True if any weight moved; False otherwise.
271
+ """
272
+ recipients = set(recipients)
273
+
274
+ # Total currently credited to the elected candidate and their surplus over quota.
275
+ tall = _tally_from_pieces(pieces, restrict_to=(recipients | {elect}))
276
+ total = tall.get(elect, 0.0)
277
+ surplus = total - quota
278
+ if surplus <= EPS or total <= EPS:
279
+ return False
280
+
281
+ scale = 10 ** decimals
282
+ any_moved = False
283
+ opened = set()
284
+
285
+ # Consider only pieces currently credited to the elected candidate.
286
+ donor_idxs = [i for i, p in enumerate(pieces) if p.cand == elect and p.weight > EPS]
287
+ if not donor_idxs:
288
+ return False
289
+
290
+ for i in donor_idxs:
291
+ p = pieces[i]
292
+
293
+ # Only papers that have a next continuing preference are "transferable" (Rule 48(1)(b)).
294
+ forwards = _move_piece_forward(p, recipients)
295
+ if not forwards:
296
+ continue
297
+
298
+ # Per‑piece transfer = floor( (surplus * value_when_received / total) * 10^d ) / 10^d
299
+ # Use arrived_value as the statute’s “value ... when received”.
300
+ base = p.arrived_value
301
+ drain = math.floor(((surplus * base / total) * scale) + EPS) / float(scale)
302
+ if drain <= EPS:
303
+ continue
304
+
305
+ # Never move more than is still credited to this piece.
306
+ drain = min(drain, p.weight)
307
+ if drain <= EPS:
308
+ continue
309
+
310
+ # Deduct from the elected candidate and forward equally among next preferences.
311
+ p.weight -= drain
312
+ share = drain / float(len(forwards))
313
+ for nxt_c, _, new_rank in forwards:
314
+ if nxt_c not in opened:
315
+ parcels.start_new_parcel(nxt_c) # start a fresh parcel for each recipient in this transfer
316
+ opened.add(nxt_c)
317
+ pieces.append(p.clone_to(nxt_c, new_rank, share))
318
+ parcels.note_arrival(nxt_c, len(pieces) - 1)
319
+
320
+ any_moved = True
321
+
322
+ return any_moved
323
+
324
+ def _eliminate_lowest(pieces, continuing, parcels, tie_break_key=None):
325
+ if not continuing:
326
+ return None, pieces
327
+ tallies = _tally_from_pieces(pieces, restrict_to=continuing)
328
+ min_t = float('inf'); lowest = []
329
+ for c in continuing:
330
+ t = tallies.get(c, 0.0)
331
+ if t < min_t - EPS:
332
+ min_t = t; lowest = [c]
333
+ elif abs(t - min_t) <= EPS:
334
+ lowest.append(c)
335
+ if not lowest:
336
+ return None, pieces
337
+ if len(lowest) > 1:
338
+ key = tie_break_key or (lambda x: str(x))
339
+ lowest.sort(key=key)
340
+ elim = lowest[0]
341
+ continuing.remove(elim)
342
+
343
+ new_pieces = []
344
+ old_to_new = {}
345
+ pending_notes = []
346
+ for old_idx, p in enumerate(pieces):
347
+ if p.cand != elim:
348
+ new_idx = len(new_pieces)
349
+ new_pieces.append(p)
350
+ old_to_new[old_idx] = new_idx
351
+ continue
352
+ forwards = _move_piece_forward(p, continuing)
353
+ if not forwards:
354
+ continue
355
+ opened = set()
356
+ share = p.weight / float(len(forwards))
357
+ for nxt_c, _, new_idx in forwards:
358
+ if nxt_c not in opened:
359
+ parcels.start_new_parcel(nxt_c)
360
+ opened.add(nxt_c)
361
+ created_idx = len(new_pieces)
362
+ new_pieces.append(p.clone_to(nxt_c, new_idx, share))
363
+ pending_notes.append((nxt_c, created_idx))
364
+ p.weight = 0.0
365
+ parcels.remap_indices(old_to_new)
366
+ for cand, idx in pending_notes:
367
+ parcels.note_arrival(cand, idx)
368
+ return elim, new_pieces
369
+
370
+ def _nb_quota(total_weight, num_seats):
371
+ return total_weight / float(num_seats + 1)
372
+
373
+ def _droop_int_quota(total_weight, num_seats):
374
+ """Integer Droop quota used in Scottish STV (SSI 2007/42, Rule 46)."""
375
+ if num_seats <= 0:
376
+ return math.inf
377
+ return math.floor(total_weight / float(num_seats + 1)) + 1
378
+
379
+ # ---------- Public STV variants ----------
380
+
381
+ @vm(name="STV-Scottish", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
382
+ def stv_scottish(profile, num_seats=2, curr_cands=None, decimals=5, rng=None):
383
+ """
384
+ Scottish STV per SSI 2007/42 (https://www.legislation.gov.uk/ssi/2007/42):
385
+ • Rule 46: Integer Droop quota q = floor(N/(k+1)) + 1
386
+ • Rule 48(3): per‑ballot transfer = truncate[(surplus × ballot value when received) / total].
387
+ • Rule 49: If multiple surpluses, transfer largest first; if equal, use *history tie‑break*,
388
+ else decide by lot.
389
+ • Rule 50: Exclusions transfer at current transfer value.
390
+ • Rule 51: Exclusion ties resolved by *history tie‑break*, else decide by lot.
391
+ • Rule 52: If continuing == vacancies remaining, elect them all; no further transfers.
392
+
393
+ Ballot ties (not allowed in actual Scottish STV) are supported by equal splitting of weight.
394
+
395
+ Args:
396
+ profile : Profile or ProfileWithTies
397
+ num_seats : int
398
+ curr_cands : iterable or None
399
+ decimals : int
400
+ Truncation precision for Rule 48(3). Default 5.
401
+ rng : random.Random-like or None
402
+ Source of randomness for “by lot” decisions. If None, uses Python's `random` module.
403
+
404
+ Returns:
405
+ list: Elected candidates (sorted by name for determinism).
406
+
407
+ .. warning::
408
+ STV implementations have not yet been thoroughly vetted.
409
+ """
410
+ if isinstance(profile, Profile):
411
+ profile = profile.to_profile_with_ties()
412
+
413
+ rand = rng if rng is not None else random
414
+
415
+ # helpers
416
+
417
+ def droop_int_quota(total_weight, k):
418
+ if k <= 0:
419
+ return math.inf
420
+ return math.floor(total_weight / float(k + 1)) + 1
421
+
422
+ def snapshot():
423
+ """Record end-of-stage totals for *all* candidates currently carrying weight."""
424
+ history.append(_tally_from_pieces(pieces))
425
+
426
+ def history_prefer(cands, prefer="highest"):
427
+ """
428
+ Apply the statute's 'most recent preceding stage where unequal' rule.
429
+ Returns a single candidate or None if still tied across all previous stages.
430
+ """
431
+ tied = list(cands)
432
+ # Walk backward over completed stages (the current moment is *after* the last snapshot)
433
+ for snap in reversed(history):
434
+ vals = [(c, snap.get(c, 0.0)) for c in tied]
435
+ if not vals:
436
+ break
437
+ if prefer == "highest":
438
+ extreme = max(v for _, v in vals)
439
+ narrowed = [c for c, v in vals if abs(v - extreme) <= EPS]
440
+ else: # prefer == "lowest"
441
+ extreme = min(v for _, v in vals)
442
+ narrowed = [c for c, v in vals if abs(v - extreme) <= EPS]
443
+ # If we strictly narrowed the field, keep going (maybe down to a singleton)
444
+ if 0 < len(narrowed) < len(tied):
445
+ tied = narrowed
446
+ if len(tied) == 1:
447
+ return tied[0]
448
+ # else: all equal at this snapshot; look further back
449
+ return None # equal at all earlier stages
450
+
451
+ def eliminate_and_transfer(elim, continuing, parcels):
452
+ """Eliminate `elim` and push their pieces forward at current values."""
453
+ new_pieces = []
454
+ old_to_new = {}
455
+ pending_notes = []
456
+ for old_idx, p in enumerate(pieces):
457
+ if p.cand != elim:
458
+ new_idx = len(new_pieces)
459
+ new_pieces.append(p)
460
+ old_to_new[old_idx] = new_idx
461
+ continue
462
+ forwards = _move_piece_forward(p, continuing)
463
+ if not forwards:
464
+ continue
465
+ opened = set()
466
+ share = p.weight / float(len(forwards))
467
+ for nxt_c, _, new_rank in forwards:
468
+ if nxt_c not in opened:
469
+ parcels.start_new_parcel(nxt_c)
470
+ opened.add(nxt_c)
471
+ created_idx = len(new_pieces)
472
+ new_pieces.append(p.clone_to(nxt_c, new_rank, share))
473
+ pending_notes.append((nxt_c, created_idx))
474
+ p.weight = 0.0
475
+ parcels.remap_indices(old_to_new)
476
+ for cand, idx in pending_notes:
477
+ parcels.note_arrival(cand, idx)
478
+ return new_pieces
479
+
480
+ # set up
481
+ continuing = set(profile.candidates) if curr_cands is None else set(curr_cands)
482
+ winners = []
483
+ parcels = ParcelIndex()
484
+ pieces = _initial_pieces_from_profile(profile, continuing, parcels)
485
+
486
+ # constant quota for the whole count (Rule 46)
487
+ _, rcounts = profile.rankings_counts
488
+ total_votes = sum(float(c) for c in rcounts)
489
+ quota = droop_int_quota(total_votes, num_seats)
490
+
491
+ # History of end-of-stage totals (for Rules 49 & 51)
492
+ history = []
493
+ snapshot() # First stage: after initial allocation of first preferences
494
+
495
+ # main count
496
+ safety = 0
497
+ while len(winners) < num_seats:
498
+ safety += 1
499
+ if safety > 50000:
500
+ raise RuntimeError("stv_scottish: loop safety tripped – no progress")
501
+
502
+ # Current totals among continuing
503
+ tallies_c = _tally_from_pieces(pieces, restrict_to=continuing)
504
+
505
+ # Elect anyone at/above quota
506
+ elected_now = [c for c in list(continuing) if tallies_c.get(c, 0.0) >= quota - EPS]
507
+ if elected_now:
508
+ # Mark as elected (they stop being 'continuing')
509
+ for c in sorted(elected_now, key=lambda x: str(x)):
510
+ continuing.remove(c)
511
+ winners.append(c)
512
+
513
+ # Rule 49: transfer surpluses one at a time, always the largest *among all elected*
514
+ # candidates who currently exceed quota (ties by history, else lot).
515
+ stuck = set()
516
+ while True:
517
+ tall_now = _tally_from_pieces(pieces) # include everyone carrying weight
518
+ # Anyone already elected whose current total still exceeds quota?
519
+ elig = [c for c in winners if tall_now.get(c, 0.0) - float(quota) > EPS and c not in stuck]
520
+ if not elig:
521
+ break
522
+
523
+ # Pick the largest surplus; if tied, apply the statute’s history tie-break; else decide by lot.
524
+ surpluses = {c: tall_now[c] - float(quota) for c in elig}
525
+ max_s = max(surpluses.values())
526
+ tied = [c for c in elig if abs(surpluses[c] - max_s) <= EPS]
527
+ if len(tied) > 1:
528
+ chosen = history_prefer(tied, prefer="highest")
529
+ if chosen is None:
530
+ chosen = rand.choice(tied)
531
+ else:
532
+ chosen = tied[0]
533
+
534
+ moved = _transfer_surplus_scottish(
535
+ pieces, chosen, float(quota),
536
+ recipients=continuing, parcels=parcels, decimals=decimals
537
+ )
538
+ if moved:
539
+ snapshot() # each successful surplus transfer creates a new "stage" for the Rule 49/51 history
540
+ else:
541
+ # nothing to move (no next prefs etc.); don't loop forever on this candidate
542
+ stuck.add(chosen)
543
+
544
+ # After finishing the surpluses from this stage, check last‑vacancy rule.
545
+ if len(continuing) <= num_seats - len(winners):
546
+ winners.extend(sorted(continuing, key=lambda x: str(x)))
547
+ break
548
+
549
+ continue # start a fresh stage
550
+
551
+ if not elected_now:
552
+ tall_now = _tally_from_pieces(pieces) # totals for everyone carrying weight
553
+ surplusers = [c for c in winners if tall_now.get(c, 0.0) - float(quota) > EPS]
554
+
555
+ if surplusers:
556
+ # Rule 49: pick the largest surplus; tie by history, else lot
557
+ max_s = max(tall_now[c] - float(quota) for c in surplusers)
558
+ tied = [c for c in surplusers if abs((tall_now[c]-float(quota)) - max_s) <= EPS]
559
+ if len(tied) > 1:
560
+ chosen = history_prefer(tied, prefer="highest") or rand.choice(tied)
561
+ else:
562
+ chosen = tied[0]
563
+
564
+ moved = _transfer_surplus_scottish(
565
+ pieces, chosen, float(quota),
566
+ recipients=continuing, parcels=parcels, decimals=decimals
567
+ )
568
+ if moved:
569
+ snapshot() # each successful surplus transfer is its own stage
570
+ continue # try again before excluding anyone
571
+
572
+ # No one newly elected → consider last vacancies (Rule 52)
573
+ if len(continuing) <= num_seats - len(winners):
574
+ winners.extend(sorted(continuing, key=lambda x: str(x)))
575
+ break
576
+
577
+ # Exclude the current lowest (Rule 50) with Rule 51 history tie-break
578
+ tallies_c = _tally_from_pieces(pieces, restrict_to=continuing)
579
+ min_t = min(tallies_c.get(c, 0.0) for c in continuing)
580
+ lowest = [c for c in continuing if abs(tallies_c.get(c, 0.0) - min_t) <= EPS]
581
+
582
+ if len(lowest) > 1:
583
+ elim = history_prefer(lowest, prefer="lowest")
584
+ if elim is None:
585
+ elim = rand.choice(lowest) # decide by lot if tied at all previous stages
586
+ else:
587
+ elim = lowest[0]
588
+
589
+ continuing.remove(elim)
590
+ pieces = eliminate_and_transfer(elim, continuing, parcels)
591
+ snapshot() # each exclusion is a new stage
592
+
593
+ return sorted(winners, key=lambda x: str(x))
594
+
595
+ @vm(name="STV-NB", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
596
+ def stv_nb(profile, num_seats = 2, curr_cands=None, quota_rule="nb", mann_strict=False, drain_all=False, tie_break_key=None, *, ers_rounding=False):
597
+ """
598
+ Single Transferable Vote — Newland–Britton (ERS) surplus rule (“NB”) with rational Droop quota.
599
+
600
+ Summary
601
+ -------
602
+ Uses the NB (rational Droop) quota n/(k+1) and the ERS/Newland–Britton *compensation* rule.
603
+ When a candidate exceeds quota, only ballot pieces that can transfer (i.e., have a next
604
+ available preference among continuing candidates) are drained; pieces that cannot transfer
605
+ are left untouched. The drain fraction α is chosen so the total drained from transferable
606
+ pieces equals the surplus, with α ≤ 1 per piece. This offsets non‑transferables rather than
607
+ letting surplus “disappear.” If many ballots are non‑transferable, an elected candidate may
608
+ remain above quota after the surplus step. (In contrast, WIG drains the same fraction from
609
+ *all* pieces, including those that cannot move; Meek lowers the effective quota via keep
610
+ factors.)
611
+
612
+ Counting details
613
+ ----------------
614
+ • Quota: NB (rational Droop) quota = total_weight / (seats + 1).
615
+ If ers_rounding=True, quota is rounded up (to integer if >100, else to hundredth) per ERS practice.
616
+ Optional "Mann strictness" (mann_strict=True) requires strictly more than the NB quota.
617
+ • Surpluses: one at a time, largest surplus first among newly elected.
618
+ • Recipients: transfers go only to continuing (unelected) candidates.
619
+ • If no surplus: eliminate the current lowest and transfer at current weights; tie broken by
620
+ `tie_break_key`.
621
+ • Ballot ties: ties on ballots are supported by equal splitting of weight.
622
+ • Last vacancies: if continuing == seats_remaining, elect them all.
623
+
624
+ References: Tideman ("The Single Transferable Vote", 1995) on ERS compensation vs Meek; and
625
+ Tideman & Richardson ("Better voting methods through technology: The refinement-manageability trade-off in the single transferable vote", 2000).
626
+
627
+ Args:
628
+ profile: A Profile or ProfileWithTies object containing voter rankings
629
+ num_seats (int): Number of seats to fill
630
+ curr_cands: List of candidates to consider, defaults to all candidates in profile
631
+ quota_rule (str): Quota calculation rule, defaults to "nb" (rational Droop)
632
+ mann_strict (bool): Whether to use strict Mann-style elimination, defaults to False
633
+ drain_all (bool): Whether to drain all ballots, defaults to False
634
+ tie_break_key: Function for tie-breaking, defaults to None
635
+ ers_rounding: If True, use ERS manual count rounding as described by Tideman and Richardson (2000): quota rounded up (to integer if >100,
636
+ else to hundredth) and transfer values rounded down to hundredth. If False, defaults to rational Droop.
637
+
638
+ Returns:
639
+ list: List of elected candidates
640
+
641
+ .. warning::
642
+ STV implementations have not yet been thoroughly vetted.
643
+ """
644
+ if isinstance(profile, Profile):
645
+ profile = profile.to_profile_with_ties()
646
+
647
+ candidates_list = list(profile.candidates) if curr_cands is None else curr_cands
648
+ continuing = set(candidates_list)
649
+ winners = []
650
+ parcels = ParcelIndex()
651
+ pieces = _initial_pieces_from_profile(profile, continuing, parcels)
652
+
653
+ # Calculate total weight from profile
654
+ rankings, rcounts = profile.rankings_counts
655
+ total_weight = sum(float(count) for count in rcounts)
656
+ if total_weight <= EPS or not continuing or num_seats <= 0:
657
+ return []
658
+
659
+ rule = (quota_rule or "nb").lower()
660
+ if rule == "nb":
661
+ raw_quota = _nb_quota(total_weight, num_seats) # rational Droop
662
+ if ers_rounding:
663
+ # ERS practice: round quota up (to integer if >100, else to hundredth)
664
+ if raw_quota > 100.0:
665
+ quota = math.ceil(raw_quota)
666
+ else:
667
+ quota = math.ceil(raw_quota * 100.0) / 100.0
668
+ else:
669
+ quota = raw_quota
670
+ elif rule == "droop":
671
+ quota = _droop_int_quota(total_weight, num_seats) # integer Droop
672
+ else:
673
+ raise ValueError(f'Unknown quota_rule "{quota_rule}". Use "nb" or "droop".')
674
+
675
+ safety = 0
676
+ while len(winners) < num_seats:
677
+ safety += 1
678
+ if safety > 20000:
679
+ raise RuntimeError("stv_nb: loop safety tripped – no progress")
680
+
681
+ tallies_c = _tally_from_pieces(pieces, restrict_to=continuing)
682
+ elected_now = [c for c in list(continuing)
683
+ if (tallies_c.get(c, 0.0) > quota + EPS if mann_strict
684
+ else tallies_c.get(c, 0.0) >= quota - EPS)]
685
+ if elected_now:
686
+ for c in sorted(elected_now, key=lambda x: str(x)):
687
+ continuing.remove(c)
688
+ winners.append(c)
689
+ _t(f"Elected now: {elected_now}")
690
+
691
+ # Surplus transfers: recipients = continuing only
692
+ stuck = set()
693
+ while True:
694
+ tall_all = _tally_from_pieces(pieces, restrict_to=set(continuing) | set(elected_now))
695
+ surplusers = [c for c in elected_now if tall_all.get(c, 0.0) - quota > EPS and c not in stuck]
696
+ if not surplusers:
697
+ break
698
+ elect = max(surplusers, key=lambda c: (tall_all.get(c, 0.0) - quota, str(c)))
699
+ moved = _transfer_surplus_inclusive(
700
+ pieces, elect, quota, recipients=continuing, parcels=parcels,
701
+ drain_all=drain_all, last_parcel_only=False, ers_rounding=ers_rounding
702
+ )
703
+ _t(f"Transfer surplus from {elect}: moved={moved}")
704
+ if not moved:
705
+ stuck.add(elect)
706
+
707
+ if len(continuing) <= num_seats - len(winners):
708
+ winners.extend(sorted(continuing, key=lambda x: str(x)))
709
+ break
710
+
711
+ continue
712
+
713
+ if len(continuing) <= num_seats - len(winners):
714
+ winners.extend(sorted(continuing, key=lambda x: str(x)))
715
+ break
716
+
717
+ elim, new_pieces = _eliminate_lowest(pieces, continuing, parcels, tie_break_key=tie_break_key)
718
+ if elim is None:
719
+ break
720
+ _t(f"Eliminate: {elim}")
721
+ pieces = new_pieces
722
+
723
+ return sorted(winners, key=lambda x: str(x))
724
+
725
+ @vm(name="STV-WIG", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
726
+ def stv_wig(profile, num_seats = 2, curr_cands=None, quota_rule="nb", drain_all=True, tie_break_key=None):
727
+ """
728
+ STV with **Weighted Inclusive Gregory** (WIG) surplus transfers.
729
+
730
+ Surpluses: drain the same fraction from every ballot in a winner’s pile; forward to next
731
+ available continuing choices (exhaust otherwise); only surpluses of candidates elected in this stage are processed; previously elected winners are not revisited later. Elimination transfers at current weights. Transfers are exact (no ERS‑style rounding).
732
+
733
+ Ballot ties are supported by equal splitting of weight.
734
+
735
+ Quota options:
736
+ • quota_rule="nb" → rational Droop: total_weight / (seats + 1)
737
+ • quota_rule="droop" → integer Droop: floor(total_weight / (seats + 1)) + 1
738
+
739
+ Note: WIG + Droop is common in public counts that use inclusive Gregory.
740
+
741
+ References: Tideman ("The Single Transferable Vote", 1995) and Tideman & Richardson ("Better voting methods through technology: The
742
+ refinement-manageability trade-off in the single transferable vote", 2000).
743
+
744
+ Args:
745
+ profile: A Profile or ProfileWithTies object containing voter rankings
746
+ num_seats (int): Number of seats to fill
747
+ curr_cands: List of candidates to consider, defaults to all candidates in profile
748
+ quota_rule (str): Quota calculation rule, defaults to "nb" (rational Droop)
749
+ tie_break_key: Function for tie-breaking, defaults to None
750
+
751
+ Returns:
752
+ list: List of elected candidates
753
+
754
+ .. warning::
755
+ STV implementations have not yet been thoroughly vetted.
756
+ """
757
+ if isinstance(profile, Profile):
758
+ profile = profile.to_profile_with_ties()
759
+
760
+ candidates_list = list(profile.candidates) if curr_cands is None else list(curr_cands)
761
+ continuing = set(candidates_list)
762
+ winners = []
763
+ parcels = ParcelIndex()
764
+ pieces = _initial_pieces_from_profile(profile, continuing, parcels)
765
+
766
+ if num_seats <= 0 or not continuing:
767
+ return []
768
+
769
+ # Compute a constant quota for the count
770
+ rankings, rcounts = profile.rankings_counts
771
+ total_weight = sum(float(count) for count in rcounts)
772
+
773
+ rule = (quota_rule or "nb").lower()
774
+ if rule == "nb":
775
+ quota = _nb_quota(total_weight, num_seats)
776
+ elif rule == "droop":
777
+ quota = _droop_int_quota(total_weight, num_seats)
778
+ else:
779
+ raise ValueError(f'Unknown quota_rule "{quota_rule}". Use "nb" or "droop".')
780
+
781
+ safety = 0
782
+ while len(winners) < num_seats:
783
+ safety += 1
784
+ if safety > 20000:
785
+ raise RuntimeError("stv_wig: loop safety tripped – no progress")
786
+
787
+ tallies_c = _tally_from_pieces(pieces, restrict_to=continuing)
788
+
789
+ # Elect everyone at/above quota
790
+ elected_now = [c for c in list(continuing) if tallies_c.get(c, 0.0) >= quota - EPS]
791
+ if elected_now:
792
+ for c in sorted(elected_now, key=lambda x: str(x)):
793
+ continuing.remove(c)
794
+ winners.append(c)
795
+
796
+ # Transfer surpluses (largest surplus first), WIG (drain_all=True)
797
+ stuck = set()
798
+ while True:
799
+ tall_all = _tally_from_pieces(pieces, restrict_to=set(continuing) | set(elected_now))
800
+ surplusers = [c for c in elected_now if tall_all.get(c, 0.0) - quota > EPS and c not in stuck]
801
+ if not surplusers:
802
+ break
803
+ elect = max(surplusers, key=lambda c: (tall_all.get(c, 0.0) - quota, str(c)))
804
+ moved = _transfer_surplus_inclusive(
805
+ pieces, elect, quota, recipients=continuing, parcels=parcels,
806
+ drain_all=True, last_parcel_only=False
807
+ )
808
+ if not moved:
809
+ stuck.add(elect)
810
+
811
+ # If remaining candidates equal remaining seats, elect them all
812
+ if len(continuing) <= num_seats - len(winners):
813
+ winners.extend(sorted(continuing, key=lambda x: str(x)))
814
+ break
815
+
816
+ continue
817
+
818
+ # No election this round — eliminate the lowest and redistribute
819
+ if len(continuing) <= num_seats - len(winners):
820
+ winners.extend(sorted(continuing, key=lambda x: str(x)))
821
+ break
822
+
823
+ elim, pieces = _eliminate_lowest(pieces, continuing, parcels, tie_break_key=tie_break_key)
824
+ if elim is None:
825
+ break
826
+
827
+ return sorted(winners, key=lambda x: str(x))
828
+
829
+ @vm(name="STV-Last-Parcel", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
830
+ def stv_last_parcel(profile, num_seats = 2, curr_cands=None, quota_rule="nb", tie_break_key=None):
831
+ """
832
+ Single Transferable Vote using the "last parcel" or "senatorial" transfer rule.
833
+
834
+ This is a variant of STV where surplus transfers work differently from the standard method.
835
+ When a candidate has more votes than the quota (surplus), instead of transferring a proportion
836
+ of all their votes, only the most recent "parcel" (bundle) of votes that put them over the
837
+ quota is transferred. This simulates the practice used in some senatorial elections. Only surpluses
838
+ of candidates elected in this stage are processed; previously elected winners are not revisited later.
839
+ Only the NB (rational Droop) quota is implemented for this variant
840
+
841
+ The last parcel rule can produce different results than standard STV because it treats
842
+ different bundles of votes differently based on when they arrived at the candidate.
843
+
844
+ Ballot ties are supported by equal splitting of weight.
845
+
846
+ References: Tideman ("The Single Transferable Vote", 1995) and Tideman & Richardson ("Better voting methods through technology: The
847
+ refinement-manageability trade-off in the single transferable vote", 2000).
848
+
849
+ Args:
850
+ profile: A Profile or ProfileWithTies object containing voter rankings
851
+ num_seats (int): Number of seats to fill
852
+ curr_cands: List of candidates to consider, defaults to all candidates in profile
853
+ quota_rule (str): Quota calculation rule, defaults to "nb" (rational Droop)
854
+ tie_break_key: Function for tie-breaking, defaults to None
855
+
856
+ Returns:
857
+ list: List of elected candidates
858
+
859
+ .. warning::
860
+ STV implementations have not yet been thoroughly vetted.
861
+ """
862
+ if isinstance(profile, Profile):
863
+ profile = profile.to_profile_with_ties()
864
+
865
+ candidates_list = list(profile.candidates) if curr_cands is None else curr_cands
866
+ continuing = set(candidates_list)
867
+ winners = []
868
+ parcels = ParcelIndex()
869
+ pieces = _initial_pieces_from_profile(profile, continuing, parcels)
870
+
871
+ # Calculate total weight from profile
872
+ rankings, rcounts = profile.rankings_counts
873
+ total_weight = sum(float(count) for count in rcounts)
874
+ if total_weight <= EPS or not continuing or num_seats <= 0:
875
+ return []
876
+ if quota_rule.lower() != "nb":
877
+ raise ValueError("Only NB quota is implemented.")
878
+ quota = _nb_quota(total_weight, num_seats)
879
+
880
+ safety = 0
881
+ while len(winners) < num_seats:
882
+ safety += 1
883
+ if safety > 20000:
884
+ raise RuntimeError("stv_last_parcel: loop safety tripped – no progress")
885
+
886
+ tallies_c = _tally_from_pieces(pieces, restrict_to=continuing)
887
+ elected_now = [c for c in list(continuing) if tallies_c.get(c, 0.0) >= quota - EPS]
888
+ if elected_now:
889
+ for c in sorted(elected_now, key=lambda x: str(x)):
890
+ continuing.remove(c)
891
+ winners.append(c)
892
+ _t(f"[LP] Elected now: {elected_now}")
893
+
894
+ for c in elected_now:
895
+ moved = _transfer_surplus_inclusive(
896
+ pieces, c, quota, recipients=continuing, parcels=parcels,
897
+ drain_all=True, last_parcel_only=True
898
+ )
899
+ _t(f"[LP] Transfer surplus (last parcel) from {c}: moved={moved}")
900
+ continue
901
+
902
+ if len(continuing) <= num_seats - len(winners):
903
+ winners.extend(sorted(continuing, key=lambda x: str(x)))
904
+ break
905
+
906
+ elim, new_pieces = _eliminate_lowest(pieces, continuing, parcels, tie_break_key=tie_break_key)
907
+ if elim is None:
908
+ break
909
+ _t(f"[LP] Eliminate: {elim}")
910
+ pieces = new_pieces
911
+
912
+ return sorted(winners, key=lambda x: str(x))
913
+
914
+ # ---------- Meek STV ----------
915
+
916
+ def _meek_flow_one_ballot(tiers, keep, continuing):
917
+ remaining = 1.0
918
+ out = []
919
+ for tier in tiers:
920
+ avail = [c for c in tier if c in continuing]
921
+ if not avail:
922
+ continue
923
+ share = remaining / float(len(avail))
924
+ spilled = 0.0
925
+ for c in avail:
926
+ k = keep.get(c, 1.0)
927
+ kept = k * share
928
+ out.append((c, kept))
929
+ spilled += (1.0 - k) * share
930
+ remaining = spilled
931
+ if remaining <= EPS:
932
+ break
933
+ return out
934
+
935
+ def _meek_tally_from_profile(profile, keep, continuing):
936
+ """Meek tally working directly with ProfileWithTies."""
937
+ t = collections.defaultdict(float)
938
+ rankings, rcounts = profile.rankings_counts
939
+
940
+ for ranking, count in zip(rankings, rcounts):
941
+ if count <= 0:
942
+ continue
943
+ rmap = ranking.rmap
944
+ by_rank = collections.defaultdict(list)
945
+ for c, r in rmap.items():
946
+ if r is not None:
947
+ by_rank[int(r)].append(c)
948
+ tiers = []
949
+ for r in sorted(by_rank):
950
+ tiers.append(sorted(by_rank[r], key=lambda x: str(x)))
951
+
952
+ if tiers:
953
+ for c, a in _meek_flow_one_ballot(tiers, keep, continuing):
954
+ t[c] += a * float(count)
955
+ return t
956
+
957
+ @vm(name="STV-Meek", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
958
+ def stv_meek(profile, num_seats = 2, curr_cands=None, tol=1e-10, max_iter=2000, tie_break_key=None):
959
+ """
960
+ Meek Single Transferable Vote using retention factors for surplus handling.
961
+
962
+ Meek STV is an STV variant that uses "retention factors" to handle surplus transfers.
963
+ Instead of transferring physical ballot papers, each candidate has a "keep factor" that
964
+ determines what fraction of votes they retain. When a candidate is elected, their keep
965
+ factor is reduced so they only keep exactly the quota, and the remainder flows to next
966
+ preferences.
967
+
968
+ This method iteratively adjusts keep factors until the system reaches equilibrium.
969
+ Elected candidates remain "continuing" throughout the process with reduced keep factors.
970
+ At each iteration, quota = (active weight currently credited to continuing candidates) / (k+1);
971
+ if no further keep‑factor reductions bring all winners to quota, eliminate the current lowest and repeat.
972
+
973
+ Ballot ties are supported by equal splitting of weight.
974
+
975
+ References: Tideman ("The Single Transferable Vote", 1995) and Tideman & Richardson ("Better voting methods through technology: The
976
+ refinement-manageability trade-off in the single transferable vote", 2000).
977
+
978
+ Args:
979
+ profile: A Profile or ProfileWithTies object containing voter rankings
980
+ num_seats (int): Number of seats to fill
981
+ curr_cands: List of candidates to consider, defaults to all candidates in profile
982
+ tol (float): Tolerance for convergence, defaults to 1e-10
983
+ max_iter (int): Maximum number of iterations, defaults to 2000
984
+ tie_break_key: Function for tie-breaking, defaults to None
985
+
986
+ Returns:
987
+ list: List of elected candidates
988
+
989
+ .. warning::
990
+ STV implementations have not yet been thoroughly vetted.
991
+ """
992
+ if isinstance(profile, Profile):
993
+ profile = profile.to_profile_with_ties()
994
+
995
+ candidates_list = list(profile.candidates) if curr_cands is None else curr_cands
996
+ continuing = set(candidates_list)
997
+ keep = {c: 1.0 for c in continuing}
998
+
999
+ # Calculate total weight from profile
1000
+ rankings, rcounts = profile.rankings_counts
1001
+ total_weight = sum(float(count) for count in rcounts)
1002
+ if total_weight <= EPS or not continuing or num_seats <= 0:
1003
+ return []
1004
+
1005
+ while len(continuing) > num_seats:
1006
+ # Adjust keep factors (winners remain continuing)
1007
+ for _ in range(max_iter):
1008
+ tallies = _meek_tally_from_profile(profile, keep, continuing)
1009
+ active_total = sum(tallies.get(c, 0.0) for c in continuing)
1010
+ quota = active_total / float(num_seats + 1) if active_total > EPS else 0.0
1011
+
1012
+ changed = False
1013
+ for c in list(continuing):
1014
+ t = tallies.get(c, 0.0)
1015
+ if t > quota + tol and keep.get(c, 1.0) > 0.0:
1016
+ # Standard Meek step: monotone, non‑increasing keep
1017
+ new_keep = min(keep[c], quota / t)
1018
+ if keep[c] - new_keep > tol:
1019
+ keep[c] = new_keep
1020
+ changed = True
1021
+ if not changed:
1022
+ break
1023
+
1024
+ # Eliminate the current lowest
1025
+ tallies = _meek_tally_from_profile(profile, keep, continuing)
1026
+ min_t = float('inf'); lowest = []
1027
+ for c in continuing:
1028
+ t = tallies.get(c, 0.0)
1029
+ if t < min_t - EPS:
1030
+ min_t = t; lowest = [c]
1031
+ elif abs(t - min_t) <= EPS:
1032
+ lowest.append(c)
1033
+ if len(lowest) > 1:
1034
+ key = tie_break_key or (lambda x: str(x))
1035
+ lowest.sort(key=key)
1036
+ elim = lowest[0]
1037
+ continuing.remove(elim)
1038
+ keep[elim] = 0.0
1039
+ _t(f"[Meek] Eliminate: {elim} (t={min_t:.6f})")
1040
+
1041
+ return sorted(list(continuing), key=lambda x: str(x))[:num_seats]
1042
+
1043
+ @vm(name="STV-Warren", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
1044
+ def stv_warren(profile, num_seats = 2, curr_cands=None, tol=1e-10, tie_break_key=None):
1045
+ """
1046
+ Warren's equal‑price STV (Tideman 1995; Tideman & Richardson 2000):
1047
+ • In each stage, allocate each ballot to its current top among continuing (split ties).
1048
+ • While any candidate exceeds the (dynamic) quota, compute a uniform price p_c so that
1049
+ sum_b min(w_{b,c}, p_c) = quota; cap every ballot's contribution to c at p_c and push
1050
+ the released weight to the next available preference(s); iterate until no one > quota.
1051
+ • If no one is over quota and too many remain, eliminate the current lowest and repeat.
1052
+ • Quota in each stage = (active weight among continuing) / (k+1).
1053
+
1054
+ Args:
1055
+ profile: A Profile or ProfileWithTies object containing voter rankings
1056
+ num_seats (int): Number of seats to fill
1057
+ curr_cands: List of candidates to consider, defaults to all candidates in profile
1058
+ tol (float): Tolerance for convergence, defaults to 1e-10
1059
+ tie_break_key: Function for tie-breaking, defaults to None
1060
+
1061
+ References: Tideman ("The Single Transferable Vote", 1995) and Tideman & Richardson ("Better voting methods through technology: The
1062
+ refinement-manageability trade-off in the single transferable vote", 2000).
1063
+
1064
+ Returns:
1065
+ list: List of elected candidates
1066
+
1067
+ .. warning::
1068
+ STV implementations have not yet been thoroughly vetted.
1069
+ """
1070
+ if isinstance(profile, Profile):
1071
+ profile = profile.to_profile_with_ties()
1072
+ continuing = list(profile.candidates) if curr_cands is None else list(curr_cands)
1073
+ rankings, rcounts = profile.rankings_counts
1074
+
1075
+ def topset(ranking, accept):
1076
+ rmap = ranking.rmap
1077
+ ranks = [r for c, r in rmap.items() if c in accept and r is not None]
1078
+ if not ranks: return []
1079
+ rmin = min(ranks)
1080
+ return [c for c, r in rmap.items() if c in accept and r == rmin]
1081
+
1082
+ def nextset(ranking, accept, exclude):
1083
+ rmap = ranking.rmap
1084
+ pool = [(c, r) for c, r in rmap.items() if c != exclude and c in accept and r is not None]
1085
+ if not pool: return []
1086
+ rmin = min(r for _, r in pool)
1087
+ return [c for c, r in pool if r == rmin]
1088
+
1089
+ while len(continuing) > num_seats:
1090
+ # (1) fresh per‑ballot allocations to current tops
1091
+ alloc = [collections.defaultdict(float) for _ in rankings]
1092
+ S = set(continuing)
1093
+ for i, ranking in enumerate(rankings):
1094
+ tops = topset(ranking, S)
1095
+ if tops:
1096
+ share = 1.0 / float(len(tops))
1097
+ for t in tops:
1098
+ alloc[i][t] += share
1099
+
1100
+ # (2) shrink over‑quota piles by equal price until none > quota
1101
+ max_equalize_iters = 2000
1102
+ iters = 0
1103
+
1104
+ while True:
1105
+ # recompute totals and the current quota
1106
+ totals = collections.defaultdict(float)
1107
+ for i, A in enumerate(alloc):
1108
+ m = float(rcounts[i])
1109
+ for c, per in A.items():
1110
+ totals[c] += per * m
1111
+ active_total = sum(totals.get(c, 0.0) for c in continuing)
1112
+ quota = active_total / float(num_seats + 1) if num_seats > 0 else float('inf')
1113
+
1114
+ over = [c for c in continuing if totals.get(c, 0.0) > quota + tol]
1115
+ if not over:
1116
+ break # all piles are at/below quota
1117
+
1118
+ iters += 1
1119
+ if iters > max_equalize_iters:
1120
+ # Safety valve: stop rather than silently under‑equalizing.
1121
+ break
1122
+
1123
+ changed = False
1124
+
1125
+ # Warren equal‑price: for each over‑quota candidate c, find p_c so
1126
+ # sum_i m_i * min(w_ic, p_c) = quota, where w_ic = per‑ballot share to c.
1127
+ for c in sorted(over, key=lambda x: str(x)):
1128
+ per_list = []
1129
+ for i, A in enumerate(alloc):
1130
+ w = A.get(c, 0.0)
1131
+ if w > EPS:
1132
+ per_list.append((w, float(rcounts[i])))
1133
+ if not per_list:
1134
+ continue
1135
+
1136
+ # bisection for p_c
1137
+ lo, hi = 0.0, max(w for w, _ in per_list)
1138
+ for _ in range(64):
1139
+ mid = 0.5 * (lo + hi)
1140
+ s = 0.0
1141
+ for w, m in per_list:
1142
+ s += m * (w if w < mid else mid)
1143
+ if s > quota:
1144
+ hi = mid
1145
+ else:
1146
+ lo = mid
1147
+ p_c = lo
1148
+
1149
+ # cap at p_c and push released weight forward (exhaust if no next)
1150
+ for i, ranking in enumerate(rankings):
1151
+ per = alloc[i].get(c, 0.0)
1152
+ if per <= EPS:
1153
+ continue
1154
+ new_per = min(per, p_c)
1155
+ delta_per = per - new_per
1156
+ if delta_per > tol:
1157
+ alloc[i][c] = new_per
1158
+ nxt = nextset(ranking, set(continuing), exclude=c)
1159
+ if nxt:
1160
+ share = delta_per / float(len(nxt)) # m cancels with later re‑weight
1161
+ for d in nxt:
1162
+ alloc[i][d] += share
1163
+ # else: delta exhausts, lowering active_total and the next quota
1164
+ changed = True
1165
+
1166
+ # If nothing changed numerically, we’re as close as floating‑point permits
1167
+ if not changed:
1168
+ break
1169
+
1170
+ # (3) eliminate lowest if too many remain
1171
+ totals = collections.defaultdict(float)
1172
+ for i, A in enumerate(alloc):
1173
+ m = float(rcounts[i])
1174
+ for c, per in A.items():
1175
+ totals[c] += per * m
1176
+
1177
+ if len(continuing) == num_seats:
1178
+ break
1179
+
1180
+ min_t = float('inf'); lowest = []
1181
+ for c in continuing:
1182
+ t = totals.get(c, 0.0)
1183
+ if t < min_t - EPS:
1184
+ min_t = t; lowest = [c]
1185
+ elif abs(t - min_t) <= EPS:
1186
+ lowest.append(c)
1187
+ if len(lowest) > 1:
1188
+ key = tie_break_key or (lambda x: str(x))
1189
+ lowest.sort(key=key)
1190
+ elim = lowest[0]
1191
+ continuing.remove(elim)
1192
+
1193
+ return sorted(list(continuing), key=lambda x: str(x))[:num_seats]
1194
+
1195
+ @vm(name="Approval-STV", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
1196
+ def approval_stv(profile, num_seats=2, curr_cands=None, quota_rule="droop",
1197
+ select_tiebreak=None, elim_tiebreak=None, rng=None):
1198
+ """
1199
+ Approval‑STV (Delemazure & Peters 2024, https://arxiv.org/abs/2404.11407):
1200
+
1201
+ In each round, a ballot supports all candidates it ranks *top* among the remaining
1202
+ candidates. Let B_i be the remaining budget of ballot i (start at 1 per voter).
1203
+ Let q be the quota.
1204
+
1205
+ Loop until k winners:
1206
+ 1) For every continuing candidate c, compute support S(c) = sum_{i: c in top_i} B_i.
1207
+ 2) If some c has enough support (strictly > q for Droop; >= q for Hare):
1208
+ Elect such a c (by default the one with largest S); charge supporters exactly q
1209
+ in total by multiplying each supporter's budget by (S(c) - q)/S(c) (Gregory);
1210
+ remove c.
1211
+ 3) Otherwise eliminate a candidate with the smallest S(c); remove it.
1212
+
1213
+ Quotas
1214
+ -------
1215
+ quota_rule="droop" → q = n / (k+1), elect if support > q
1216
+ quota_rule="droop_int" → q = floor(n / (k+1)) + 1, elect if support > q
1217
+ quota_rule="hare" → q = n / k, elect if support ≥ q
1218
+
1219
+ Notes
1220
+ -----
1221
+ • Matches the budget‑flow pseudocode in Fig. 12 (Approval‑STV) using Gregory charging.
1222
+ • Equals Approval‑IRV when k=1 with Hare quota (but not with Droop; see Remark 5.1).
1223
+
1224
+ Args:
1225
+ profile: A Profile or ProfileWithTies object containing voter rankings
1226
+ num_seats (int): Number of seats to fill
1227
+ curr_cands: List of candidates to consider, defaults to all candidates in profile
1228
+ quota_rule (str): Quota rule to use, defaults to "droop"
1229
+ select_tiebreak: Function for tie-breaking, defaults to None
1230
+ elim_tiebreak: Function for tie-breaking, defaults to None
1231
+ rng: Random number generator, defaults to None
1232
+
1233
+ Returns:
1234
+ list: List of elected candidates
1235
+
1236
+ .. warning::
1237
+ Approval‑STV implementation has not yet been thoroughly vetted.
1238
+ """
1239
+
1240
+ if isinstance(profile, Profile):
1241
+ profile = profile.to_profile_with_ties()
1242
+ rankings, rcounts = profile.rankings_counts
1243
+
1244
+ continuing = list(profile.candidates) if curr_cands is None else [c for c in curr_cands if c in profile.candidates]
1245
+ winners = []
1246
+
1247
+ # Total number of voters (with multiplicities)
1248
+ n = float(sum(rcounts))
1249
+ if n <= EPS or num_seats <= 0 or not continuing:
1250
+ return []
1251
+
1252
+ # Select quota and election inequality
1253
+ if quota_rule == "droop":
1254
+ quota = n / float(num_seats + 1); strict = True
1255
+ elif quota_rule == "droop_int":
1256
+ quota = math.floor(n / float(num_seats + 1)) + 1; strict = True
1257
+ elif quota_rule == "hare":
1258
+ quota = n / float(num_seats); strict = False
1259
+ else:
1260
+ raise ValueError("quota_rule must be one of {'droop','droop_int','hare'}")
1261
+
1262
+ # Budgets are tracked per ranking type, scaled by multiplicity
1263
+ budgets = [float(c) for c in rcounts]
1264
+
1265
+ def topset(ranking, accept):
1266
+ rmap = ranking.rmap
1267
+ ranks = [r for c, r in rmap.items() if c in accept and r is not None]
1268
+ if not ranks:
1269
+ return []
1270
+ rmin = min(ranks)
1271
+ return [c for c, r in rmap.items() if c in accept and r == rmin]
1272
+
1273
+ def support_budgets(accept):
1274
+ S = collections.defaultdict(float)
1275
+ A = set(accept)
1276
+ for i, ranking in enumerate(rankings):
1277
+ b = budgets[i]
1278
+ if b <= EPS:
1279
+ continue
1280
+ tops = topset(ranking, A)
1281
+ for c in tops:
1282
+ S[c] += b
1283
+ for c in accept:
1284
+ S.setdefault(c, 0.0)
1285
+ return S
1286
+
1287
+ def electable(S):
1288
+ if strict:
1289
+ return [c for c, v in S.items() if v > quota + EPS]
1290
+ else:
1291
+ return [c for c, v in S.items() if v + EPS >= quota]
1292
+
1293
+ def charge_supporters(chosen, S, accept):
1294
+ """Multiply each supporter's budget by a common factor so the total charge is exactly q."""
1295
+ total = S.get(chosen, 0.0)
1296
+ if total <= EPS:
1297
+ return
1298
+ factor = max(0.0, (total - quota) / total)
1299
+ A = set(accept)
1300
+ for i, ranking in enumerate(rankings):
1301
+ if budgets[i] <= EPS:
1302
+ continue
1303
+ if chosen in topset(ranking, A):
1304
+ budgets[i] *= factor
1305
+
1306
+ rand = rng or random.Random(0)
1307
+
1308
+ while len(winners) < num_seats and continuing:
1309
+ # Early finish: fill remaining seats if #continuing == seats_left
1310
+ if len(continuing) <= num_seats - len(winners):
1311
+ winners.extend(sorted(continuing, key=lambda x: str(x)))
1312
+ break
1313
+
1314
+ S = support_budgets(continuing)
1315
+
1316
+ # Elect if any candidate's supporters exceed the quota
1317
+ elig = electable(S)
1318
+ if elig:
1319
+ # default: pick largest support, deterministic by name if tied
1320
+ if select_tiebreak is None:
1321
+ maxv = max(S[c] for c in elig)
1322
+ tied = [c for c in elig if abs(S[c] - maxv) <= EPS]
1323
+ chosen = sorted(tied, key=lambda x: str(x))[0]
1324
+ else:
1325
+ best = max(select_tiebreak(c) for c in elig)
1326
+ tied = [c for c in elig if abs(select_tiebreak(c) - best) <= EPS]
1327
+ chosen = rand.choice(sorted(tied, key=lambda x: str(x)))
1328
+ winners.append(chosen)
1329
+ # supporters are with respect to the pre‑removal set (which includes `chosen`)
1330
+ charge_supporters(chosen, S, continuing + [chosen])
1331
+ continuing.remove(chosen)
1332
+ _t(f"[Approval‑STV] Elect {chosen}; winners: {winners}")
1333
+ continue
1334
+
1335
+ # Otherwise, eliminate a lowest‑supported candidate
1336
+ minv = min(S[c] for c in continuing)
1337
+ lowest = [c for c in continuing if abs(S[c] - minv) <= EPS]
1338
+ if len(lowest) > 1:
1339
+ if elim_tiebreak is None:
1340
+ elim = sorted(lowest, key=lambda x: str(x))[0]
1341
+ else:
1342
+ mink = min(elim_tiebreak(c) for c in lowest)
1343
+ tied = [c for c in lowest if abs(elim_tiebreak(c) - mink) <= EPS]
1344
+ elim = rand.choice(sorted(tied, key=lambda x: str(x)))
1345
+ else:
1346
+ elim = lowest[0]
1347
+ continuing.remove(elim)
1348
+ _t(f"[Approval‑STV] Eliminate {elim}")
1349
+
1350
+ return sorted(winners, key=lambda x: str(x))
1351
+
1352
+ # ---------- CPO-STV ----------
1353
+
1354
+ def _committee_margin_pwt(A, B, profile, inpair_surplus="meek"):
1355
+ """
1356
+ Compute the pairwise margin (A over B) for CPO‑STV using a *pair‑specific* quota.
1357
+ Steps (Tideman 1995/Tideman & Richardson 2000):
1358
+ • Restrict to S = A ∪ B. Allocate each ballot to its top(s) in S (split ties equally).
1359
+ • Let I = A ∩ B. Transfer *only* surpluses of candidates in I until no I‑member
1360
+ exceeds the pair‑quota; do NOT transfer surpluses of candidates outside I.
1361
+ • Pair‑quota at each iteration = (usable weight inside S) / (k+1), where k = |A|.
1362
+ Weight that has no next preference within S exhausts, lowering the next quota.
1363
+ • inpair_surplus = "meek" (ratio shrink) or "warren" (equal‑price).
1364
+ Returns: float margin = sum(A) − sum(B).
1365
+ """
1366
+ rankings, rcounts = profile.rankings_counts
1367
+
1368
+ def _topset_in_ranking(ranking, accept_set):
1369
+ rmap = ranking.rmap
1370
+ ranks = [r for c, r in rmap.items() if c in accept_set and r is not None]
1371
+ if not ranks:
1372
+ return []
1373
+ rmin = min(ranks)
1374
+ return [c for c, r in rmap.items() if c in accept_set and r == rmin]
1375
+
1376
+ def _nextset_in_ranking(ranking, accept_set, exclude):
1377
+ rmap = ranking.rmap
1378
+ pool = [(c, r) for c, r in rmap.items()
1379
+ if c != exclude and c in accept_set and r is not None]
1380
+ if not pool:
1381
+ return []
1382
+ rmin = min(r for _, r in pool)
1383
+ return [c for c, r in pool if r == rmin]
1384
+
1385
+ S = set(A) | set(B)
1386
+ I = set(A) & set(B)
1387
+ k = len(A)
1388
+
1389
+ # Per‑row allocations (already multiplied by row multiplicities)
1390
+ bal_alloc = [collections.defaultdict(float) for _ in rankings]
1391
+ for i, (ranking, m) in enumerate(zip(rankings, rcounts)):
1392
+ tops = _topset_in_ranking(ranking, S)
1393
+ if not tops:
1394
+ continue
1395
+ share = float(m) / float(len(tops))
1396
+ for t in tops:
1397
+ bal_alloc[i][t] += share
1398
+
1399
+ tol = 1e-12
1400
+ max_iters = 10000
1401
+ rule = (inpair_surplus or "meek").lower()
1402
+
1403
+ for _ in range(max_iters):
1404
+ # Recompute totals and the *pair‑specific* quota from current usable weight in S.
1405
+ totals = collections.defaultdict(float)
1406
+ for alloc in bal_alloc:
1407
+ for c, w in alloc.items():
1408
+ totals[c] += w
1409
+ usable = sum(totals.get(c, 0.0) for c in S)
1410
+ if k == 0 or usable <= tol:
1411
+ break
1412
+ quota = usable / float(k + 1)
1413
+
1414
+ changed = False
1415
+ for c in sorted(I, key=lambda x: str(x)):
1416
+ tc = totals.get(c, 0.0)
1417
+ excess = tc - quota
1418
+ if excess <= tol or tc <= tol:
1419
+ continue
1420
+
1421
+ if rule == "warren":
1422
+ # Equal‑price per Warren: choose p_c with Σ_i min(w_ic, p_c) = quota.
1423
+ w_list = []
1424
+ for i, alloc in enumerate(bal_alloc):
1425
+ w_c = alloc.get(c, 0.0)
1426
+ if w_c <= 0.0:
1427
+ continue
1428
+ m = float(rcounts[i])
1429
+ per = w_c / m
1430
+ w_list.append((per, m))
1431
+ if not w_list:
1432
+ continue
1433
+ lo, hi = 0.0, max(w for w, _ in w_list)
1434
+ for _ in range(64):
1435
+ mid = 0.5 * (lo + hi)
1436
+ s = 0.0
1437
+ for w, m in w_list:
1438
+ s += m * (w if w < mid else mid)
1439
+ if s > quota:
1440
+ hi = mid
1441
+ else:
1442
+ lo = mid
1443
+ p_c = lo
1444
+
1445
+ # Cap and push inside S; if no next in S, the delta exhausts.
1446
+ for i, ranking in enumerate(rankings):
1447
+ w_c = bal_alloc[i].get(c, 0.0)
1448
+ if w_c <= 0.0:
1449
+ continue
1450
+ m = float(rcounts[i])
1451
+ per = w_c / m
1452
+ new_per = min(per, p_c)
1453
+ delta_total = (per - new_per) * m
1454
+ if delta_total <= tol:
1455
+ continue
1456
+ bal_alloc[i][c] = new_per * m
1457
+ nxt = _nextset_in_ranking(ranking, S, exclude=c)
1458
+ if nxt:
1459
+ share = delta_total / float(len(nxt))
1460
+ for nx in nxt:
1461
+ bal_alloc[i][nx] = bal_alloc[i].get(nx, 0.0) + share
1462
+ changed = True
1463
+
1464
+ else:
1465
+ # Meek‑like ratio shrink: remove the same *fraction* from each piece for c.
1466
+ ratio = excess / tc
1467
+ for i, ranking in enumerate(rankings):
1468
+ w_c = bal_alloc[i].get(c, 0.0)
1469
+ if w_c <= 0.0:
1470
+ continue
1471
+ delta = w_c * ratio
1472
+ if delta <= tol:
1473
+ continue
1474
+ bal_alloc[i][c] = w_c - delta
1475
+ nxt = _nextset_in_ranking(ranking, S, exclude=c)
1476
+ if nxt:
1477
+ share = delta / float(len(nxt))
1478
+ for nx in nxt:
1479
+ bal_alloc[i][nx] = bal_alloc[i].get(nx, 0.0) + share
1480
+ changed = True
1481
+
1482
+ if not changed:
1483
+ break
1484
+
1485
+ # Final totals & margin
1486
+ totals = collections.defaultdict(float)
1487
+ for alloc in bal_alloc:
1488
+ for c, w in alloc.items():
1489
+ totals[c] += w
1490
+ score_A = sum(totals.get(c, 0.0) for c in A)
1491
+ score_B = sum(totals.get(c, 0.0) for c in B)
1492
+ return score_A - score_B
1493
+
1494
+ @vm(name="CPO-STV", input_types=[ElectionTypes.PROFILE, ElectionTypes.PROFILE_WITH_TIES])
1495
+ def cpo_stv(profile, num_seats = 2, curr_cands=None, inpair_surplus="meek", fallback_vm=minimax):
1496
+ """
1497
+ CPO-STV (Comparison of Pairs of Outcomes) - a Condorcet-consistent proportional method.
1498
+
1499
+ Unlike traditional STV which eliminates candidates sequentially, CPO-STV considers all
1500
+ possible committees (combinations) of the required size and compares them pairwise.
1501
+
1502
+ For any two k‑member sets A and B, restrict each ballot to S = A ∪ B, allocate the
1503
+ ballot to its highest ranked available candidate in S, and then transfer **only**
1504
+ the surpluses of candidates in the intersection I = A ∩ B (never from candidates
1505
+ who appear in only one of the two compared sets). The margin of A vs. B is the sum of
1506
+ votes for A’s members minus the sum for B’s.
1507
+
1508
+ Within each A vs B comparison, the quota is q = U/(k+1), where k = |A| and
1509
+ U is the total weight currently credited to candidates in S
1510
+ (i.e., not yet exhausted relative to S). When, at the point of transfer,
1511
+ a ballot has no remaining ranked candidate in S, its remaining weight is
1512
+ treated as exhausted for this comparison, which reduces U on subsequent iterations.
1513
+
1514
+ The winning committee is the one that beats all other possible committees
1515
+ in these pairwise comparisons. This makes CPO-STV "Condorcet-consistent" - if there's
1516
+ a committee that is majority-preferred to every other committee, CPO-STV will find it.
1517
+ If there is no such Condorcet committee, then the fallback voting method is used to
1518
+ pick the winning committee based on the pairwise margins between committees.
1519
+
1520
+ This method is computationally intensive as it must examine C(candidates, seats) committees.
1521
+
1522
+ References: Tideman ("The Single Transferable Vote", 1995) and Tideman & Richardson ("Better voting methods through technology: The
1523
+ refinement-manageability trade-off in the single transferable vote", 2000).
1524
+
1525
+ Args:
1526
+ profile: A Profile or ProfileWithTies object containing voter rankings
1527
+ num_seats (int): Number of seats to fill
1528
+ curr_cands: List of candidates to consider, defaults to all candidates in profile
1529
+ inpair_surplus (str): Surplus handling method for pairwise comparisons, defaults to "meek"
1530
+ fallback_vm: Fallback voting method for tie-breaking, defaults to minimax
1531
+
1532
+ Returns:
1533
+ list: List of elected candidates forming the winning committee
1534
+
1535
+ .. warning::
1536
+ This implementation of CPO-STV has not yet been thoroughly vetted.
1537
+ """
1538
+ if isinstance(profile, Profile):
1539
+ profile = profile.to_profile_with_ties()
1540
+
1541
+ curr_cands = list(profile.candidates) if curr_cands is None else curr_cands
1542
+ committees = list(itertools.combinations(curr_cands, num_seats))
1543
+
1544
+ if len(committees) <= 1:
1545
+ return list(committees[0]) if committees else []
1546
+
1547
+ # For efficiency, we first check for a Condorcet committee using an algorithm that does not require constructing the full margin graph.
1548
+ condorcet_committee_exists = True
1549
+ C = committees[0]
1550
+ for A in committees:
1551
+ if _committee_margin_pwt(A, C, profile, inpair_surplus=inpair_surplus) > 0:
1552
+ C = A
1553
+
1554
+ for B in committees:
1555
+ if C != B and not _committee_margin_pwt(C, B, profile, inpair_surplus=inpair_surplus) > 0:
1556
+ condorcet_committee_exists = False
1557
+ break
1558
+
1559
+ if condorcet_committee_exists:
1560
+ return list(C)
1561
+
1562
+ # If no Condorcet committee exists, we construct the full margin graph and use the fallback voting method to find the winning committee.
1563
+ weighted_edges = []
1564
+ for i, A in enumerate(committees):
1565
+ for B in committees[i+1:]:
1566
+ m = _committee_margin_pwt(A, B, profile, inpair_surplus=inpair_surplus)
1567
+ if m > 0:
1568
+ weighted_edges.append((A, B, m))
1569
+ elif m < 0:
1570
+ weighted_edges.append((B, A, abs(m)))
1571
+
1572
+ mg = MarginGraph(committees, weighted_edges)
1573
+ winners = fallback_vm(mg)
1574
+
1575
+ return sorted(random.choice(winners))