From 34f361a4a6b7c0ccf4cb17a0b0765b4f395964d5 Mon Sep 17 00:00:00 2001 From: "Peter J. Holzer" Date: Fri, 12 May 2023 21:38:52 +0200 Subject: [PATCH 1/3] Reverse direction of instant runoff --- app.py | 39 ++++++++++++++++++++++++++++----------- utils/instantrunoff | 6 +++++- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/app.py b/app.py index c679ea4..3d4f54e 100644 --- a/app.py +++ b/app.py @@ -173,7 +173,7 @@ def vote_date(): (session["user"]["id"], meet_id,)) dates = csr.fetchall() - result = instantrunoff_forward(meet_id, "date") + result = instantrunoff_backward(meet_id, "date") log.debug("result = %s", result) csr.execute( @@ -194,7 +194,7 @@ def vote_date(): @app.get("/result//date") def result_date(meet_id): - result = instantrunoff_forward(meet_id, "date") + result = instantrunoff_backward(meet_id, "date") log.debug("result = %s", result) csr = get_cursor() @@ -251,7 +251,7 @@ def vote_time(): (session["user"]["id"], meet_id,)) times = csr.fetchall() - result = instantrunoff_forward(meet_id, "time") + result = instantrunoff_backward(meet_id, "time") log.debug("result = %s", result) csr.execute( @@ -272,7 +272,7 @@ def vote_time(): @app.get("/result//time") def result_time(meet_id): - result = instantrunoff_forward(meet_id, "time") + result = instantrunoff_backward(meet_id, "time") log.debug("result = %s", result) csr = get_cursor() @@ -327,7 +327,7 @@ def vote_place(): (session["user"]["id"], meet_id,)) places = csr.fetchall() - result = instantrunoff_forward(meet_id, "place") + result = instantrunoff_backward(meet_id, "place") log.debug("result = %s", result) csr.execute( @@ -349,7 +349,7 @@ def vote_place(): @app.get("/result//place") def result_place(meet_id): - result = instantrunoff_forward(meet_id, "place") + result = instantrunoff_backward(meet_id, "place") log.debug("result = %s", result) csr = get_cursor() @@ -373,7 +373,7 @@ def result_place(meet_id): @app.get("/result//date/ballot") def result_ballots_date(meet_id): ballots = get_ballots(meet_id, "date") - result = instantrunoff_forward(meet_id, "date") + result = instantrunoff_backward(meet_id, "date") return render_template( "date_result_ballots.html", @@ -382,7 +382,7 @@ def result_ballots_date(meet_id): @app.get("/result//time/ballot") def result_ballots_time(meet_id): ballots = get_ballots(meet_id, "time") - result = instantrunoff_forward(meet_id, "time") + result = instantrunoff_backward(meet_id, "time") return render_template( "time_result_ballots.html", @@ -391,7 +391,7 @@ def result_ballots_time(meet_id): @app.get("/result//place/ballot") def result_ballots_place(meet_id): ballots = get_ballots(meet_id, "place") - result = instantrunoff_forward(meet_id, "place") + result = instantrunoff_backward(meet_id, "place") return render_template( "place_result_ballots.html", @@ -453,7 +453,10 @@ def runoff(ballots): log.debug("pos = %s", pos) count[r.id][pos] += 1 log.debug("count[%d][%d]) = %d", r.id, pos, count[r.id][pos]) - result = sorted(count.keys(), key=lambda i: count[i]) + if direction == "backward": + result = sorted(count.keys(), key=lambda i: list(reversed(count[i])), reverse=True) + else: + result = sorted(count.keys(), key=lambda i: count[i]) log.debug("result of this round:") for r in result: log.debug("%s %s", r, count[r]) @@ -472,7 +475,21 @@ def instantrunoff_forward(meet_id, kind): result = [] while max(len(b) for b in ballots): dump_ballots(ballots) - loser, ballots = runoff(ballots) + loser, ballots = runoff(ballots, "forward") + result.append(loser) + result = list(reversed(result)) + log.debug("final result") + for r in result: + log.debug(r) + return result + +def instantrunoff_backward(meet_id, kind): + ballots = get_ballots(meet_id, kind) + + result = [] + while max(len(b) for b in ballots): + dump_ballots(ballots) + loser, ballots = runoff(ballots, "backward") result.append(loser) result = list(reversed(result)) log.debug("final result") diff --git a/utils/instantrunoff b/utils/instantrunoff index a663c76..85d3d52 100755 --- a/utils/instantrunoff +++ b/utils/instantrunoff @@ -7,6 +7,7 @@ import psycopg.rows def get_args(): ap = argparse.ArgumentParser() + ap.add_argument("--reverse", action="store_true") ap.add_argument("meet", type=int) ap.add_argument("kind") @@ -53,7 +54,10 @@ def runoff(ballots): for ballot in ballots: for pos, r in enumerate(ballot): count[r.id][pos] += 1 - result = sorted(count.keys(), key=lambda i: count[i]) + if args.reverse: + result = sorted(count.keys(), key=lambda i: list(reversed(count[i])), reverse=True) + else: + result = sorted(count.keys(), key=lambda i: count[i]) print("result of this round:") for r in result: print(r, count[r]) From 187718f360b5676b0acbe78e293f91135f95218d Mon Sep 17 00:00:00 2001 From: "Peter J. Holzer" Date: Sun, 14 May 2023 12:59:11 +0200 Subject: [PATCH 2/3] Weight ballots by their their best option If the only options left are ones where a person can't make it, their ballot shouldn't influence the result any more. Similarily when there are only options which are questionable that ballot should have a lower weight. We do this by attaching a weight to each option, where real choices get a weight of 1 and pseudo-choices (like "none of the above") get a lower weight (theoretically that should be 0, but let's keep that configurable). When constructing the ballots each vote gets a monotonically decreasing weight. As options are eliminated in each round, the weight of the ballot is recalculated as the maximum weight of all remaining options. So this starts at 1 for all ballots, but drops as more favourable options are eliminated, possibly down to 0 where a ballot ceases to have an effect. --- app.py | 6 ++++-- utils/instantrunoff | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 3d4f54e..6261c9e 100644 --- a/app.py +++ b/app.py @@ -412,7 +412,8 @@ def get_ballots(meet_id, kind): csr = get_cursor() q = f""" - select {kind}.*, bod, position, email + select {kind}.*, bod, position, email, + min(w) over(partition by bod order by position) as vote_w from {kind} join {kind}_vote on {kind}.id = {kind}_vote.{kind} join bod on bod = bod.id @@ -447,11 +448,12 @@ def runoff(ballots): count[r.id] = [0] * len(ballot) candidates[r.id] = r for ballot in ballots: + weight = max(r.vote_w for r in ballot) for pos, r in enumerate(ballot): log.debug("count = %s", count) log.debug("r.id = %s", r.id) log.debug("pos = %s", pos) - count[r.id][pos] += 1 + count[r.id][pos] += weight log.debug("count[%d][%d]) = %d", r.id, pos, count[r.id][pos]) if direction == "backward": result = sorted(count.keys(), key=lambda i: list(reversed(count[i])), reverse=True) diff --git a/utils/instantrunoff b/utils/instantrunoff index 85d3d52..93b4afe 100755 --- a/utils/instantrunoff +++ b/utils/instantrunoff @@ -21,7 +21,8 @@ def get_ballots(): kind = args.kind q = f""" - select {kind}.*, bod, position + select {kind}.*, bod, position, + min(w) over(partition by bod order by position) as vote_w from {kind} join {kind}_vote on {kind}.id = {kind}_vote.{kind} where meet = %s order by bod, position @@ -52,8 +53,9 @@ def runoff(ballots): count[r.id] = [0] * len(ballot) candidates[r.id] = r for ballot in ballots: + weight = max(r.vote_w for r in ballot) for pos, r in enumerate(ballot): - count[r.id][pos] += 1 + count[r.id][pos] += weight if args.reverse: result = sorted(count.keys(), key=lambda i: list(reversed(count[i])), reverse=True) else: From aa9e609446de482d265e1b058f66754c2239c8e0 Mon Sep 17 00:00:00 2001 From: "Peter J. Holzer" Date: Sun, 14 May 2023 13:15:58 +0200 Subject: [PATCH 3/3] Add option direction to definition of runoff If we're going to call it with a second parameter we should also define it to expect one ;-) --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index 3d4f54e..d7f0186 100644 --- a/app.py +++ b/app.py @@ -437,7 +437,7 @@ def dump_ballots(ballots): for r in ballot: log.debug(r) -def runoff(ballots): +def runoff(ballots, direction): count = {} candidates = {} for ballot in ballots: