From e3edc80f2badd04446e2c0dc4732056444a12c54 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Wed, 23 Dec 2020 22:36:49 +1100 Subject: [PATCH] Begin implementation of tie handling --- build.sh | 2 +- html/worker.js | 8 ++++ pyRCV2/cli/stv.py | 16 ++++++- pyRCV2/method/base_stv.py | 59 ++++++++++++++++++++++++-- pyRCV2/numbers/fixed_js.py | 2 + pyRCV2/numbers/fixed_py.py | 2 + pyRCV2/numbers/int_js.py | 2 + pyRCV2/numbers/int_py.py | 2 + pyRCV2/numbers/native_js.py | 2 + pyRCV2/numbers/native_py.py | 2 + pyRCV2/numbers/rational_js.py | 2 + pyRCV2/numbers/rational_py.py | 2 + pyRCV2/ties.py | 78 +++++++++++++++++++++++++++++++++++ pyRCV2/transcrypt.py | 1 + 14 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 pyRCV2/ties.py diff --git a/build.sh b/build.sh index e96ffc4..973d0c1 100755 --- a/build.sh +++ b/build.sh @@ -22,4 +22,4 @@ perl -0777 -i -pe 's#key \(a\) > key \(b\) \?#__gt__\(key \(a\), key \(b\)\) \?# rollup __target__/pyRCV2.transcrypt.js -o $OUTFILE --name py -f iife --no-treeshake || exit 1 # Patch incorrectly generated iterator functions -perl -0777 -i -pe "s#'__index0__'#__index0__#g" $OUTFILE || exit 1 +#perl -0777 -i -pe "s#'__index0__'#__index0__#g" $OUTFILE || exit 1 diff --git a/html/worker.js b/html/worker.js index ea335de..ee4c9c0 100644 --- a/html/worker.js +++ b/html/worker.js @@ -49,6 +49,14 @@ onmessage = function(evt) { counter = py.pyRCV2.method.base_stv.BaseWIGSTVCounter(election, evt.data.options); } + if (evt.data.options['ties'] === 'backwards_random') { + counter.options['ties'] = [py.pyRCV2.ties.TiesBackwards(), py.pyRCV2.ties.TiesRandom()]; + } else if (evt.data.options['ties'] === 'random') { + counter.options['ties'] = [py.pyRCV2.ties.TiesRandom()]; + } //else if (evt.data.options['ties'] === 'prompt') { + // counter.options['ties'] = [py.pyRCV2.ties.TiesPrompt()]; + //} + // Reset let result = counter.reset(); diff --git a/pyRCV2/cli/stv.py b/pyRCV2/cli/stv.py index dcf0f6e..6fc2096 100644 --- a/pyRCV2/cli/stv.py +++ b/pyRCV2/cli/stv.py @@ -20,18 +20,20 @@ import pyRCV2.numbers from pyRCV2.method.base_stv import BaseUIGSTVCounter, BaseWIGSTVCounter from pyRCV2.method.wright import WrightSTVCounter +from pyRCV2.ties import TiesBackwards, TiesPrompt, TiesRandom def add_parser(subparsers): parser = subparsers.add_parser('stv', help='single transferable vote') parser.add_argument('file', help='path to BLT file') parser.add_argument('--quota', '-q', choices=['droop', 'droop_exact', 'hare', 'hare_exact'], default='droop', help='quota calculation (default: droop)') - parser.add_argument('--quota-criterion', '-qc', choices=['geq', 'gt'], default='geq', help='quota criterion (default: geq)') + parser.add_argument('--quota-criterion', '-c', choices=['geq', 'gt'], default='geq', help='quota criterion (default: geq)') parser.add_argument('--prog-quota', action='store_true', help='progressively reducing quota') parser.add_argument('--numbers', '-n', choices=['fixed', 'rational', 'int', 'native'], default='fixed', help='numbers mode (default: fixed)') parser.add_argument('--decimals', type=int, default=5, help='decimal places if --numbers fixed (default: 5)') parser.add_argument('--surplus-order', '-s', choices=['size', 'order'], default='size', help='whether to distribute surpluses by size or by order of election (default: size)') parser.add_argument('--method', '-m', choices=['wig', 'uig', 'wright'], default='wig', help='method of surpluses and exclusions (default: wig - weighted inclusive Gregory)') + parser.add_argument('--ties', '-t', action='append', choices=['backwards', 'prompt', 'random'], default=None, help='how to resolve ties (default: backwards then random)') def print_step(result): print(result.comment) @@ -80,6 +82,18 @@ def main(args): else: counter = BaseWIGSTVCounter(election, vars(args)) + if args.ties is None: + args.ties = ['backwards', 'random'] + + counter.options['ties'] = [] + for t in args.ties: + if t == 'backwards': + counter.options['ties'].append(TiesBackwards()) + elif t == 'prompt': + counter.options['ties'].append(TiesPrompt()) + elif t == 'random': + counter.options['ties'].append(TiesRandom()) + # Reset result = counter.reset() diff --git a/pyRCV2/method/base_stv.py b/pyRCV2/method/base_stv.py index c4ceed0..ffa085d 100644 --- a/pyRCV2/method/base_stv.py +++ b/pyRCV2/method/base_stv.py @@ -19,6 +19,7 @@ __pragma__ = lambda x: None from pyRCV2.model import CandidateState, CountCard, CountCompleted, CountStepResult from pyRCV2.numbers import Num, Rational from pyRCV2.safedict import SafeDict +from pyRCV2.ties import TiesBackwards, TiesPrompt, TiesRandom class STVException(Exception): pass @@ -39,6 +40,7 @@ class BaseSTVCounter: 'quota': 'droop', # 'droop', 'droop_exact', 'hare' or 'hare_exact' 'quota_criterion': 'geq', # 'geq' or 'gt' 'surplus_order': 'size', # 'size' or 'order' + 'ties': [TiesBackwards(), TiesRandom()] } if options is not None: @@ -174,12 +176,13 @@ class BaseSTVCounter: # Distribute surpluses in specified order if self.options['surplus_order'] == 'size': has_surplus.sort(key=lambda x: x[1].votes, reverse=True) + candidate_surplus, count_card = self.choose_highest(has_surplus) # May need to break ties elif self.options['surplus_order'] == 'order': has_surplus.sort(key=lambda x: x[1].order_elected) + candidate_surplus, count_card = has_surplus[0] # Ties were already broken when these were assigned else: raise STVException('Invalid surplus order option') - candidate_surplus, count_card = has_surplus[0] count_card.state = CandidateState.ELECTED __pragma__('opov') @@ -253,8 +256,7 @@ class BaseSTVCounter: hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL] hopefuls.sort(key=lambda x: x[1].votes) - # TODO: Handle ties - candidate_excluded, count_card = hopefuls[0] + candidate_excluded, count_card = self.choose_lowest(hopefuls) return candidate_excluded, count_card @@ -312,11 +314,60 @@ class BaseSTVCounter: meets_quota = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL and self.meets_quota(cc)] if len(meets_quota) > 0: + meets_quota.sort(key=lambda x: x[1].votes, reverse=True) + # Declare elected any candidate who meets the quota - for candidate, count_card in meets_quota: + while len(meets_quota) > 0: + x = self.choose_highest(meets_quota) + candidate, count_card = x[0], x[1] + count_card.state = CandidateState.PROVISIONALLY_ELECTED count_card.order_elected = self.num_elected self.num_elected += 1 + + meets_quota.remove(x) + + def choose_lowest(self, l): + """ + Provided a list of tuples (Candidate, CountCard), sorted in ASCENDING order of votes, choose the tuple with the fewest votes, breaking ties appropriately + """ + + if len(l) == 1: + return l[0] + + tied = [(c, cc) for c, cc in l if cc.votes == l[0][1].votes] + + if len(tied) == 1: + return tied[0] + + # A tie exists + for tie in self.options['ties']: + result = tie.choose_lowest(tied) + if result is not None: + return result + + raise Exception('Unable to resolve tie') + + def choose_highest(self, l): + """ + Provided a list of tuples (Candidate, CountCard), sorted in DESCENDING order of votes, choose the tuple with the most votes, breaking ties appropriately + """ + + if len(l) == 1: + return l[0] + + tied = [(c, cc) for c, cc in l if cc.votes == l[0][1].votes] + + if len(tied) == 1: + return tied[0] + + # A tie exists + for tie in self.options['ties']: + result = tie.choose_highest(tied) + if result is not None: + return result + + raise Exception('Unable to resolve tie') class BaseWIGSTVCounter(BaseSTVCounter): """ diff --git a/pyRCV2/numbers/fixed_js.py b/pyRCV2/numbers/fixed_js.py index 0ce24a6..7115022 100644 --- a/pyRCV2/numbers/fixed_js.py +++ b/pyRCV2/numbers/fixed_js.py @@ -48,6 +48,8 @@ class Fixed: def __div__(self, other): return Fixed(self.impl.div(other.impl)) + def __eq__(self, other): + return self.impl.eq(other.impl) def __gt__(self, other): return self.impl.gt(other.impl) def __ge__(self, other): diff --git a/pyRCV2/numbers/fixed_py.py b/pyRCV2/numbers/fixed_py.py index 26801ea..fbbfe4e 100644 --- a/pyRCV2/numbers/fixed_py.py +++ b/pyRCV2/numbers/fixed_py.py @@ -52,6 +52,8 @@ class Fixed: def __truediv__(self, other): return Fixed(self.impl / other.impl) + def __eq__(self, other): + return self.impl == other.impl def __gt__(self, other): return self.impl > other.impl def __ge__(self, other): diff --git a/pyRCV2/numbers/int_js.py b/pyRCV2/numbers/int_js.py index 3815c0f..a4a733b 100644 --- a/pyRCV2/numbers/int_js.py +++ b/pyRCV2/numbers/int_js.py @@ -43,6 +43,8 @@ class NativeInt: def __div__(self, other): return NativeInt(self.impl / other.impl) + def __eq__(self, other): + return self.impl == other.impl def __gt__(self, other): return self.impl > other.impl def __ge__(self, other): diff --git a/pyRCV2/numbers/int_py.py b/pyRCV2/numbers/int_py.py index fe91112..c8bcf8d 100644 --- a/pyRCV2/numbers/int_py.py +++ b/pyRCV2/numbers/int_py.py @@ -45,6 +45,8 @@ class NativeInt: def __truediv__(self, other): return NativeInt(self.impl / other.impl) + def __eq__(self, other): + return self.impl == other.impl def __gt__(self, other): return self.impl > other.impl def __ge__(self, other): diff --git a/pyRCV2/numbers/native_js.py b/pyRCV2/numbers/native_js.py index 80f2d26..ed0e0e3 100644 --- a/pyRCV2/numbers/native_js.py +++ b/pyRCV2/numbers/native_js.py @@ -43,6 +43,8 @@ class Native: def __div__(self, other): return Native(self.impl / other.impl) + def __eq__(self, other): + return self.impl == other.impl def __gt__(self, other): return self.impl > other.impl def __ge__(self, other): diff --git a/pyRCV2/numbers/native_py.py b/pyRCV2/numbers/native_py.py index b8f7d9e..2d3aee0 100644 --- a/pyRCV2/numbers/native_py.py +++ b/pyRCV2/numbers/native_py.py @@ -45,6 +45,8 @@ class Native: def __truediv__(self, other): return Native(self.impl / other.impl) + def __eq__(self, other): + return self.impl == other.impl def __gt__(self, other): return self.impl > other.impl def __ge__(self, other): diff --git a/pyRCV2/numbers/rational_js.py b/pyRCV2/numbers/rational_js.py index 7883072..02765fa 100644 --- a/pyRCV2/numbers/rational_js.py +++ b/pyRCV2/numbers/rational_js.py @@ -53,6 +53,8 @@ class Rational: def __div__(self, other): return Rational(self.impl.divide(other.impl)) + def __eq__(self, other): + return self.impl.equals(other.impl) def __gt__(self, other): return self.impl.greater(other.impl) def __ge__(self, other): diff --git a/pyRCV2/numbers/rational_py.py b/pyRCV2/numbers/rational_py.py index 6b79cbb..1e02ecc 100644 --- a/pyRCV2/numbers/rational_py.py +++ b/pyRCV2/numbers/rational_py.py @@ -54,6 +54,8 @@ class Rational: def __truediv__(self, other): return Rational(self.impl / other.impl) + def __eq__(self, other): + return self.impl == other.impl def __gt__(self, other): return self.impl > other.impl def __ge__(self, other): diff --git a/pyRCV2/ties.py b/pyRCV2/ties.py new file mode 100644 index 0000000..043db14 --- /dev/null +++ b/pyRCV2/ties.py @@ -0,0 +1,78 @@ +# pyRCV2: Preferential vote counting +# Copyright © 2020 Lee Yingtong Li (RunasSudo) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__pragma__ = lambda x: None +is_py = False +__pragma__('skip') +is_py = True +__pragma__('noskip') + +class TiesPrompt: + """Prompt the user to break ties""" + + def choose_lowest(self, l): + if is_py: + print('Multiple tied candidates:') + for i, x in enumerate(l): + print('{}. {}'.format(i + 1, x[0].name)) + + while True: + choice = input('Which candidate to select? ') + try: + choice = int(choice) + if choice >= 1 and choice < len(l) + 1: + break + except ValueError: + pass + + print() + + return l[i - 1] + else: + # UNUSED IN JS - Cannot call window.prompt from Web Worker + # TODO: Work this out + message = '' + for i, x in enumerate(l): + message += (i + 1) + '. ' + x[0].name + message += 'Which candidate to select?' + + while True: + choice = window.prompt(message) + try: + choice = int(choice) + if choice >= 1 and choice < len(l) + 1: + break + except ValueError: + pass + + return l[i - 1] + + def choose_highest(self, l): + return self.choose_lowest(l) + +class TiesBackwards: + def choose_lowest(self, l): + raise Exception('Not yet implemented') + + def choose_highest(self, l): + raise Exception('Not yet implemented') + +class TiesRandom: + def choose_lowest(self, l): + raise Exception('Not yet implemented') + + def choose_highest(self, l): + raise Exception('Not yet implemented') diff --git a/pyRCV2/transcrypt.py b/pyRCV2/transcrypt.py index aa09038..236bec8 100644 --- a/pyRCV2/transcrypt.py +++ b/pyRCV2/transcrypt.py @@ -18,5 +18,6 @@ import pyRCV2.blt import pyRCV2.model import pyRCV2.method, pyRCV2.method.base_stv, pyRCV2.method.wright import pyRCV2.numbers +import pyRCV2.ties __pragma__('js', '{}', 'export {pyRCV2, isinstance, repr, str};')