From 06ab13361501ffa4ca889c0667a52fc3fe6f6d4b Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Thu, 24 Dec 2020 00:04:30 +1100 Subject: [PATCH] Implement random breaking of ties --- html/index.html | 7 ++++--- html/index.js | 8 ++++++++ html/worker.js | 10 ++++------ pyRCV2/cli/stv.py | 8 +++++++- pyRCV2/method/base_stv.py | 13 +++++++++---- pyRCV2/random/__init__.py | 28 +++++++++++++++++++++++++++ pyRCV2/random/sharandom_js.py | 32 +++++++++++++++++++++++++++++++ pyRCV2/random/sharandom_py.py | 36 +++++++++++++++++++++++++++++++++++ pyRCV2/ties.py | 11 +++++++++-- pyRCV2/transcrypt.py | 1 + 10 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 pyRCV2/random/__init__.py create mode 100644 pyRCV2/random/sharandom_js.py create mode 100644 pyRCV2/random/sharandom_py.py diff --git a/html/index.html b/html/index.html index 6c38bd2..5894ef6 100644 --- a/html/index.html +++ b/html/index.html @@ -103,15 +103,15 @@
@@ -119,6 +119,7 @@ + diff --git a/html/index.js b/html/index.js index 76df842..14b9783 100644 --- a/html/index.js +++ b/html/index.js @@ -278,6 +278,14 @@ async function clickCount() { 'surplus_order': document.getElementById('selSurplus').value, 'ties': document.getElementById('selTies').value }, + 'seed': document.getElementById('txtSeed').value, 'data': text }); } + +// Provide a default seed +if (document.getElementById('txtSeed').value === '') { + function pad(x) { if (x < 10) { return '0' + x; } return '' + x; } + let d = new Date(); + document.getElementById('txtSeed').value = d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()); +} diff --git a/html/worker.js b/html/worker.js index ee4c9c0..0825879 100644 --- a/html/worker.js +++ b/html/worker.js @@ -16,7 +16,7 @@ along with this program. If not, see . */ -importScripts('http://peterolson.github.com/BigRational.js/BigInt_BigRat.min.js', 'https://cdn.jsdelivr.net/npm/big.js@6.0.0/big.min.js', 'bundle.js'); +importScripts('http://peterolson.github.com/BigRational.js/BigInt_BigRat.min.js', 'https://cdn.jsdelivr.net/npm/big.js@6.0.0/big.min.js', 'https://cdn.jsdelivr.net/npm/sjcl@1.0.8/sjcl.min.js', 'bundle.js'); onmessage = function(evt) { // Set settings @@ -50,12 +50,10 @@ onmessage = function(evt) { } if (evt.data.options['ties'] === 'backwards_random') { - counter.options['ties'] = [py.pyRCV2.ties.TiesBackwards(), py.pyRCV2.ties.TiesRandom()]; + counter.options['ties'] = [py.pyRCV2.ties.TiesBackwards(), py.pyRCV2.ties.TiesRandom(evt.data.seed)]; } 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()]; - //} + counter.options['ties'] = [py.pyRCV2.ties.TiesRandom(evt.data.seed)]; + } // Reset let result = counter.reset(); diff --git a/pyRCV2/cli/stv.py b/pyRCV2/cli/stv.py index 6fc2096..2c0bfe2 100644 --- a/pyRCV2/cli/stv.py +++ b/pyRCV2/cli/stv.py @@ -22,6 +22,8 @@ from pyRCV2.method.base_stv import BaseUIGSTVCounter, BaseWIGSTVCounter from pyRCV2.method.wright import WrightSTVCounter from pyRCV2.ties import TiesBackwards, TiesPrompt, TiesRandom +import sys + def add_parser(subparsers): parser = subparsers.add_parser('stv', help='single transferable vote') parser.add_argument('file', help='path to BLT file') @@ -34,6 +36,7 @@ def add_parser(subparsers): 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)') + parser.add_argument('--random-seed', default=None, help='arbitrary string used to seed the RNG for random tie breaking') def print_step(result): print(result.comment) @@ -92,7 +95,10 @@ def main(args): elif t == 'prompt': counter.options['ties'].append(TiesPrompt()) elif t == 'random': - counter.options['ties'].append(TiesRandom()) + if args.random_seed is None: + print('A --random-seed is required to use random tie breaking') + sys.exit(1) + counter.options['ties'].append(TiesRandom(args.random_seed)) # Reset result = counter.reset() diff --git a/pyRCV2/method/base_stv.py b/pyRCV2/method/base_stv.py index ffa085d..f33186f 100644 --- a/pyRCV2/method/base_stv.py +++ b/pyRCV2/method/base_stv.py @@ -19,7 +19,6 @@ __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 @@ -40,7 +39,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()] + 'ties': [] } if options is not None: @@ -335,7 +334,10 @@ class BaseSTVCounter: if len(l) == 1: return l[0] - tied = [(c, cc) for c, cc in l if cc.votes == l[0][1].votes] + __pragma__('opov') + # Do not use (c, cc) for c, cc in ... as this will break equality in JS + tied = [x for x in l if x[1].votes == l[0][1].votes] + __pragma__('noopov') if len(tied) == 1: return tied[0] @@ -356,7 +358,10 @@ class BaseSTVCounter: if len(l) == 1: return l[0] - tied = [(c, cc) for c, cc in l if cc.votes == l[0][1].votes] + __pragma__('opov') + # Do not use (c, cc) for c, cc in ... as this will break equality in JS + tied = [x for x in l if x[1].votes == l[0][1].votes] + __pragma__('noopov') if len(tied) == 1: return tied[0] diff --git a/pyRCV2/random/__init__.py b/pyRCV2/random/__init__.py new file mode 100644 index 0000000..cc5772d --- /dev/null +++ b/pyRCV2/random/__init__.py @@ -0,0 +1,28 @@ +# 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') + +if is_py: + __pragma__('skip') + from pyRCV2.random.sharandom_py import SHARandom + __pragma__('noskip') +else: + from pyRCV2.random.sharandom_js import SHARandom diff --git a/pyRCV2/random/sharandom_js.py b/pyRCV2/random/sharandom_js.py new file mode 100644 index 0000000..81ea1a3 --- /dev/null +++ b/pyRCV2/random/sharandom_js.py @@ -0,0 +1,32 @@ +# 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 . + +class SHARandom: + MAX_VAL = bigInt(2).pow(256).subtract(1) + + def __init__(self, seed): + self.seed = seed + self.ctr = 0 + + def next(self, modulus): + val = bigInt(sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(self.seed + ',' + str(self.ctr))), 16) + self.ctr += 1 + + if val.greaterOrEquals(SHARandom.MAX_VAL.divide(modulus).multiply(modulus)): + # Discard this value to avoid bias + return self.next(modulus) + + return val.mod(modulus) diff --git a/pyRCV2/random/sharandom_py.py b/pyRCV2/random/sharandom_py.py new file mode 100644 index 0000000..7322ab4 --- /dev/null +++ b/pyRCV2/random/sharandom_py.py @@ -0,0 +1,36 @@ +# 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 . + +import hashlib + +class SHARandom: + MAX_VAL = 2 ** 256 - 1 + + def __init__(self, seed): + self.seed = seed + self.ctr = 0 + + def next(self, modulus): + c = hashlib.sha256() + c.update((self.seed + ',' + str(self.ctr)).encode('utf-8')) + self.ctr += 1 + val = int.from_bytes(c.digest(), byteorder='big', signed=False) + + if val >= (SHARandom.MAX_VAL // modulus) * modulus: + # Discard this value to avoid bias + return self.next(modulus) + + return val % modulus diff --git a/pyRCV2/ties.py b/pyRCV2/ties.py index 043db14..4a22e88 100644 --- a/pyRCV2/ties.py +++ b/pyRCV2/ties.py @@ -20,6 +20,8 @@ __pragma__('skip') is_py = True __pragma__('noskip') +from pyRCV2.random import SHARandom + class TiesPrompt: """Prompt the user to break ties""" @@ -71,8 +73,13 @@ class TiesBackwards: raise Exception('Not yet implemented') class TiesRandom: + def __init__(self, seed): + self.random = SHARandom(seed) + def choose_lowest(self, l): - raise Exception('Not yet implemented') + l.sort(key=lambda x: x[0].name) + return l[self.random.next(len(l))] def choose_highest(self, l): - raise Exception('Not yet implemented') + l.sort(key=lambda x: x[0].name) + return l[self.random.next(len(l))] diff --git a/pyRCV2/transcrypt.py b/pyRCV2/transcrypt.py index 236bec8..752be3a 100644 --- a/pyRCV2/transcrypt.py +++ b/pyRCV2/transcrypt.py @@ -18,6 +18,7 @@ import pyRCV2.blt import pyRCV2.model import pyRCV2.method, pyRCV2.method.base_stv, pyRCV2.method.wright import pyRCV2.numbers +import pyRCV2.random import pyRCV2.ties __pragma__('js', '{}', 'export {pyRCV2, isinstance, repr, str};')