-
Notifications
You must be signed in to change notification settings - Fork 1
/
stv.sql
402 lines (401 loc) · 15.1 KB
/
stv.sql
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
--
prepare election_results(integer) as
with recursive
-- parameters
params(election_id) as (values ($1)),
-- candidates in this election
cur_candidates
as (select *
from candidates
where election_id = (select election_id from params)),
--
-- This takes the raw ballot prefs and presents them in the original
-- format (array of candidate ids in preference order). The
-- following considerations apply:
--
-- 1. ("undervoting") It's explicitly legal for a ballot to omit
-- candidates (we allow either null preference or missing row to
-- stand for this). The list of preferences reflects only the
-- candidates marked, and the ballot will be discarded if it gets to
-- the end of its preference list. A ballot with no valid
-- preferences at all must be discarded (since it must not affect
-- the quota).
--
-- 2. ("overvoting") If a preference value appears more than once,
-- then this is considered an overvote and invalidates the
-- preference and all numerically higher preference values, but if
-- any lower (more preferred) preferences exist they are still
-- honoured. Again, if the first preference is invalid, then we have
-- to discard the ballot outright.
--
-- 3. Otherwise, only the relative numeric ranking of preferences
-- matters, the actual values do not.
--
-- Implementation: we detect duplicate prefs by using a count(*)
-- over the peers of each row; if it's not 1 then it's invalid,
-- and we use an every() (aka bool_and()) aggregate to propagate
-- the invalidity to all lower (less-preferred) preferences.
--
-- There might be some convenience in pulling cooked_ballots out
-- of this query entirely and making it a view.
--
cooked_ballots
as (select election_id,
voter_id,
array_agg(candidate_id order by preference)
filter (where valid_pref)
as prefs
from (select election_id,
voter_id,
candidate_id,
preference,
every(valid_pref)
over (partition by election_id, voter_id
order by preference asc
range between unbounded preceding
and current row)
as valid_pref
from (select election_id,
voter_id,
candidate_id,
preference,
1 = count(*) over (partition by election_id, voter_id
order by preference asc
range current row)
as valid_pref
from ballot_preferences
where election_id = (select election_id from params)
and preference is not null) s1
) s2
group by election_id, voter_id
having bool_or(valid_pref)),
-- common data about the whole election:
-- vacancies = how many candidates to elect
-- num_ballots = total valid ballots cast
-- quota = the quota for election
-- the use of integer division for the quota calculation is intentional
cdata
as (select vacancies,
num_ballots,
(num_ballots / (vacancies + 1)) + 1 as quota
from (select (select count(*) from cooked_ballots) as num_ballots,
e.num_vacancies as vacancies
from elections e
where e.election_id = (select election_id from params)
offset 0) s),
-- "work_ballots" at each stage of the recursion is the working list
-- of ballots. Initially, all ballots have "final_result" = false;
-- when a candidate is elected or eliminated, we issue them a dummy
-- ballot with "final_result"=true, which becomes the query result.
--
-- Another kind of dummy ballot, with vote_value=0, is issued at the
-- outset to every candidate to guarantee that all candidates are
-- represented (until elected or eliminated) in the ballot list.
-- This simplifies the handling of candidates with no
-- first-preference votes at some counting stage (such candidates
-- might be eliminated, but might not). A subtlety is that this
-- dummy ballot has priority=0, which won't match the priority of
-- the candidate's real ballots (if any); but we are careful to use
-- max() when computing the effective priority, so the 0 only shows
-- up if there are no other ballots, in which case 0 is the correct
-- priority to use.
--
-- We might elect more than one person in a round (if more than one
-- person has reached quota), but we only transfer one set of
-- ballots each time. Any untransferred ballots for elected
-- candidates are marked "pending_xfer", but are otherwise unchanged
-- other than by removal of retiring candidates from their remainder
-- list. If there are pending transfers, then exclusions are not
-- processed.
--
-- The "priority" field is used to break ties when transferring
-- votes or eliminating candidates. The actual value is not
-- important, but the ranking must reflect the counting history; the
-- candidate with the larger vote count on the most recent round in
-- which their vote counts differed must have the higher priority.
-- We do this by using, after the first round, a ranking over (order
-- by votes, priority) using the current round's votes and the
-- previous round's priority.
--
-- An additional dummy row (with candidate=NULL, final_result=true)
-- is issued for every round, with a json blob in the "annotations"
-- column; this is to allow inspection of the intermediate counts
-- and report the details of the count process.
work_ballots(
candidate, -- candidate at the front of the prefs list
remainder, -- remainder of the prefs list, if any
priority, -- elimination priority of this candidate
vote_value, -- value of this ballot for this candidate
vacancies, -- number of remaining vacancies
round, -- which round this is
final_result, -- "final" result flag
pending_xfer, -- "pending transfer" flag
annotations -- json annotations added in processing
) as (select prefs[1],
prefs[2:],
count(*) over (partition by prefs[1]),
1::numeric,
(select vacancies from cdata),
1,
false,
false,
null::jsonb
from cooked_ballots
where prefs[1] is not null
union all
select c.candidate_id,
array[]::integer[],
0,
0,
(select vacancies from cdata),
1,
false,
false,
null::jsonb
from cur_candidates c
union all
-- recursive term starts here.
(with
-- we can only access the recursive term once, so suck it
-- into a CTE.
w as (select * from work_ballots
where not final_result
and vacancies > 0),
-- raw totals for candidates, including pending-xfer ones
-- (who are already elected but still have ballots in the
-- pool).
raw_score
as (select *,
s.votes - s.quota as surplus,
(s.votes - s.quota)/nullif(s.votes,0)
as transfer_value
from (select w.candidate,
max(w.priority) as priority,
max(w.round) as round,
sum(w.vote_value) as votes,
(select quota from cdata) as quota,
max(w.vacancies) as vacancies,
bool_or(pending_xfer) as pending_xfer
from w
group by candidate) s),
-- Use the computed totals to determine possible fates for
-- the candidate in this round. Not all the fates are final
-- at this point, in particular "excludable" is only used
-- if no candidate is elected or ballot transfer made.
c_score
as (select *,
s.votes >= s.quota
as electable,
1 = rank() over (order by s.votes)
as excludable,
s.vacancies >= count(*) over ()
as exhaustable
from raw_score s
where not s.pending_xfer),
-- first elect all the candidates over quota, or if we have
-- enough vacancies to elect everyone left, do so.
elect
as (select *
from c_score
where electable or exhaustable),
-- if nobody was elected this round, and there are no
-- surpluses yet to transfer, we must exclude someone. the
-- excluded candidate must have the lowest number of votes,
-- and in case of a tie, the lowest priority, and in case
-- of a tie of that, the lowest precast lot for this round.
-- We do the selection in two steps so that we can document
-- whether the priority or lot was used.
excludable
as (select cs.candidate,
cs.quota,
cs.round,
rank() over (order by cs.votes,
cs.priority,
c.precast_lots[cs.round])
as rank,
c.precast_lots[round] as lot
from c_score cs
join cur_candidates c on (cs.candidate = c.candidate_id)
where cs.excludable
and not exists (select 1 from raw_score where pending_xfer)
and not exists (select 1 from elect)),
exclude
as (select *
from excludable
where rank = 1),
-- we need to decide, if there are any surpluses or
-- exclusions, whose votes will be transferred this round.
-- We consider both candidates with pending transfers (who
-- are already elected) and any candidates with surpluses
-- this round. We then arrange to transfer the votes of the
-- candidate with the largest surplus, or the highest
-- priority, or the highest precast lot for this round.
-- Note that a candidate might be elected with no surplus
-- if they hit the quota exactly; if so, we still transfer
-- out a pending surplus if one exists. If we're excluding
-- a candidate instead, then there will be only one row
-- chosen according to the _lowest_ votecount/priority
-- already.
surpluses
as (select s.candidate,
s.transfer_value,
s.ranking = 1 as do_transfer,
s.priority,
s.lot
from (select rs.candidate,
rs.transfer_value,
row_number()
over (order by rs.votes desc,
rs.priority desc,
c.precast_lots[round] desc)
as ranking,
rs.priority,
c.precast_lots[round] as lot
from raw_score rs
join cur_candidates c
on (rs.candidate=c.candidate_id)
where rs.surplus > 0
union all
select e.candidate, 1, 1, null, null
from exclude e) s),
-- this is the candidate(s) we're retiring from the
-- election in this round, whether by election or
-- elimination or exhaustion. In the exhaustion case, this
-- will be every remaining candidate (who are all treated
-- as elected).
retire(
candidate,
quota,
round,
nelect
) as (select candidate, quota, round, 1
from elect
union all
select candidate, 0, round, 0
from exclude),
-- for the candidate chosen for transfer, we generate a new
-- set of rows for their transferred ballots. The transfer
-- value is determined by the elected/excluded candidate,
-- but the priority must be updated (later) to match the
-- new first preference. Since we might be retiring
-- multiple candidates even though we transfer only one, we
-- have to clean out the remainder list _before_ picking
-- out the new first preference.
--
-- Do this in two steps to collect exhausted ballots for
-- reporting purposes.
raw_transfer
as (select s.remainder[1] as candidate,
s.remainder[2:] as remainder,
trunc(s.vote_value * s.transfer_value,5)
as vote_value,
s.vacancies,
s.round,
false as pending_xfer
from (select w.vote_value,
sp.transfer_value,
w.vacancies,
w.round,
filter_out(w.remainder,
array(select candidate from retire))
as remainder
from w
join surpluses sp on (sp.candidate=w.candidate)
where sp.do_transfer) s),
transfer
as (select *
from raw_transfer
where candidate is not null),
-- collect all existing ballots that will proceed to next
-- round; we must remove the retiring candidates from the
-- preference lists. Be sure to preserve order when
-- removing candidates. The complication is that we keep
-- ballots from retiring candidates, with their first
-- preference unaltered, if, and only if, they have
-- surpluses that are not being transferred this round.
-- i.e.:
-- candidate is not retiring - keep
-- candidate is retiring with an untransferred surplus - keep
-- candidate is retiring with no surplus or a transferred one
-- -- discard
keep
as (select w.candidate,
filter_out(w.remainder,
array(select candidate from retire))
as remainder,
w.vote_value,
w.vacancies,
w.round,
s.candidate is not null as pending_xfer
from w
left join surpluses s on (s.candidate=w.candidate)
left join retire r on (r.candidate=w.candidate)
where r.candidate is null
or s.do_transfer is false),
-- compile annotations
annotation(round,json)
as (select r.round,
to_jsonb(a)
from (select round from w limit 1) r,
(select (select json_agg(c_score) from c_score) as c_score,
(select json_agg(s) from surpluses s) as surpluses,
(select json_agg(e) from exclude e) as excludable,
(select json_agg(k) from (select candidate, vote_value, count(*)
from keep
group by candidate, vote_value) k)
as keep,
(select json_agg(t) from (select candidate, vote_value, count(*)
from raw_transfer
group by candidate, vote_value) t)
as transferred_ballots
) a),
-- prepare the new ballot list for next time, and append
-- the result row for the retiring candidates. Also output
-- an annotation row for the round, so that the caller can
-- document the count process.
new_ballots
as (select s.candidate,
s.remainder,
dense_rank() over (order by cs.votes, cs.priority)
as priority,
s.vote_value,
s.vacancies
- (select coalesce(sum(nelect)::integer,0) from retire)
as vacancies,
s.round + 1 as round,
false as final_result,
s.pending_xfer,
null::jsonb
from (select * from transfer
union all
select * from keep) s
join c_score cs on (s.candidate=cs.candidate)
union all
select r.candidate,
null,
null,
r.quota, -- 0 for excluded, quota for elected
null,
r.round,
true,
false,
null
from retire r
union all
select null, null, null, null, null, a.round, true, false, a.json
from annotation a)
-- return output to next pass
select * from new_ballots)
)
-- final output:
select candidate,
candidate_name,
case when vote_value > 0 then 'ELECTED'
when vote_value = 0 then 'ELIMINATED'
end as result,
round,
jsonb_pretty(annotations)
from work_ballots w
left join cur_candidates c on (w.candidate=c.candidate_id)
where final_result
order by vote_value desc nulls last, round;