Public: anyone can read this; the crew can change it.
Three quorum rules as one pure module: majority, all, a named few #11
Check · Review · Land · Live done — Landed and live in production.
ledger/quorum:ledger/3-three-quorum-rules-as-one-pure-module-ma → main 1 commit fork
2 files
changed +574
−0
src/quorum.test.ts added +381
| @@ -0,0 +1,381 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { | |
| 3 | type Ballot, | |
| 4 | formatRule, | |
| 5 | parseRule, | |
| 6 | RULES, | |
| 7 | type Rule, | |
| 8 | tally, | |
| 9 | } from "./quorum.ts"; | |
| 10 | ||
| 11 | const OPTIONS = [1, 2, 3]; | |
| 12 | const ROSTER = ["alice", "bob", "carol", "dave", "erin", "frank", "gwen"]; | |
| 13 | const vote = (voter: string, optionId: number): Ballot => ({ voter, optionId }); | |
| 14 | ||
| 15 | test("the three rules each come with a line a page can print", () => { | |
| 16 | expect(RULES.map((r) => r.kind)).toEqual(["majority", "all", "named"]); | |
| 17 | for (const rule of RULES) { | |
| 18 | expect(rule.label.length).toBeGreaterThan(0); | |
| 19 | expect(rule.description.endsWith(".")).toBe(true); | |
| 20 | } | |
| 21 | }); | |
| 22 | ||
| 23 | test("parseRule reads the three spellings and nothing else", () => { | |
| 24 | const accepted: [string, Rule][] = [ | |
| 25 | ["majority", { kind: "majority" }], | |
| 26 | ["all", { kind: "all" }], | |
| 27 | [ | |
| 28 | "named:alice,bob,carol:2", | |
| 29 | { kind: "named", who: ["alice", "bob", "carol"], needed: 2 }, | |
| 30 | ], | |
| 31 | ["named:alice:1", { kind: "named", who: ["alice"], needed: 1 }], | |
| 32 | [ | |
| 33 | "named:a.b,c-d,e_f:3", | |
| 34 | { kind: "named", who: ["a.b", "c-d", "e_f"], needed: 3 }, | |
| 35 | ], | |
| 36 | [ | |
| 37 | `named:${"x".repeat(64)}:1`, | |
| 38 | { kind: "named", who: ["x".repeat(64)], needed: 1 }, | |
| 39 | ], | |
| 40 | ]; | |
| 41 | for (const [text, rule] of accepted) { | |
| 42 | expect(parseRule(text)).toEqual(rule); | |
| 43 | expect(formatRule(rule)).toBe(text); | |
| 44 | } | |
| 45 | ||
| 46 | const thirtyTwo = Array.from({ length: 32 }, (_, i) => `p${i}`); | |
| 47 | expect(parseRule(`named:${thirtyTwo.join(",")}:32`)).toEqual({ | |
| 48 | kind: "named", | |
| 49 | who: thirtyTwo, | |
| 50 | needed: 32, | |
| 51 | }); | |
| 52 | }); | |
| 53 | ||
| 54 | test("parseRule refuses, without throwing, everything it does not know", () => { | |
| 55 | const refused: [string, unknown][] = [ | |
| 56 | ["an unknown kind", "unanimous"], | |
| 57 | ["a kind in the wrong case", "Majority"], | |
| 58 | ["a kind with trailing text", "majority:alice:1"], | |
| 59 | ["surrounding space", " majority"], | |
| 60 | ["trailing space", "all "], | |
| 61 | ["nothing at all", ""], | |
| 62 | ["named with no parts", "named"], | |
| 63 | ["named with no threshold", "named:alice,bob"], | |
| 64 | ["an empty name list", "named::2"], | |
| 65 | ["an empty name in the list", "named:alice,,bob:2"], | |
| 66 | ["a repeated name", "named:alice,alice:2"], | |
| 67 | ["a name of 65 characters", `named:${"x".repeat(65)}:1`], | |
| 68 | [ | |
| 69 | "33 names", | |
| 70 | `named:${Array.from({ length: 33 }, (_, i) => `p${i}`).join(",")}:1`, | |
| 71 | ], | |
| 72 | ["a space in a name", "named:al ice:1"], | |
| 73 | ["a slash in a name", "named:alice/bob:1"], | |
| 74 | ["a quote in a name", `named:alice",bob:1`], | |
| 75 | ["needed below one", "named:alice,bob:0"], | |
| 76 | ["needed above the list", "named:alice,bob:3"], | |
| 77 | ["needed that is negative", "named:alice,bob:-1"], | |
| 78 | ["needed that is a fraction", "named:alice,bob:1.5"], | |
| 79 | ["needed with a leading zero", "named:alice,bob:01"], | |
| 80 | ["needed that is not a number", "named:alice,bob:two"], | |
| 81 | ["needed that is empty", "named:alice,bob:"], | |
| 82 | ["text longer than any rule", `named:${"a,".repeat(4000)}b:1`], | |
| 83 | ["a number", 2], | |
| 84 | ["null", null], | |
| 85 | ["undefined", undefined], | |
| 86 | ["an object", { kind: "majority" }], | |
| 87 | ]; | |
| 88 | for (const [why, text] of refused) | |
| 89 | expect([why, parseRule(text as string)]).toEqual([why, undefined]); | |
| 90 | }); | |
| 91 | ||
| 92 | const MAJORITY: Rule = { kind: "majority" }; | |
| 93 | const ALL: Rule = { kind: "all" }; | |
| 94 | const NAMED: Rule = { | |
| 95 | kind: "named", | |
| 96 | who: ["alice", "bob", "carol"], | |
| 97 | needed: 2, | |
| 98 | }; | |
| 99 | ||
| 100 | test("majority: below, at and above four of seven", () => { | |
| 101 | const below = tally({ | |
| 102 | rule: MAJORITY, | |
| 103 | roster: ROSTER, | |
| 104 | optionIds: OPTIONS, | |
| 105 | ballots: [ | |
| 106 | vote("alice", 1), | |
| 107 | vote("bob", 1), | |
| 108 | vote("carol", 1), | |
| 109 | vote("dave", 2), | |
| 110 | ], | |
| 111 | }); | |
| 112 | expect(below.decided).toBe(false); | |
| 113 | expect(below.winner).toBeUndefined(); | |
| 114 | expect(below.needed).toBe(4); | |
| 115 | expect(below.of).toBe(7); | |
| 116 | expect(below.reason).toBe("3 of 7 — a majority needs 4"); | |
| 117 | expect(below.counts).toEqual([ | |
| 118 | { optionId: 1, votes: 3, counting: 3 }, | |
| 119 | { optionId: 2, votes: 1, counting: 1 }, | |
| 120 | { optionId: 3, votes: 0, counting: 0 }, | |
| 121 | ]); | |
| 122 | ||
| 123 | const at = tally({ | |
| 124 | rule: MAJORITY, | |
| 125 | roster: ROSTER, | |
| 126 | optionIds: OPTIONS, | |
| 127 | ballots: [ | |
| 128 | vote("alice", 1), | |
| 129 | vote("bob", 1), | |
| 130 | vote("carol", 1), | |
| 131 | vote("dave", 1), | |
| 132 | ], | |
| 133 | }); | |
| 134 | expect(at.decided).toBe(true); | |
| 135 | expect(at.winner).toBe(1); | |
| 136 | expect(at.reason).toBe("4 of 7 — a majority needs 4"); | |
| 137 | ||
| 138 | const above = tally({ | |
| 139 | rule: MAJORITY, | |
| 140 | roster: ROSTER, | |
| 141 | optionIds: OPTIONS, | |
| 142 | ballots: [...ROSTER.slice(0, 5).map((p) => vote(p, 2))], | |
| 143 | }); | |
| 144 | expect(above.decided).toBe(true); | |
| 145 | expect(above.winner).toBe(2); | |
| 146 | expect(above.reason).toBe("5 of 7 — a majority needs 4"); | |
| 147 | }); | |
| 148 | ||
| 149 | test("majority is integer arithmetic on an even roster too", () => { | |
| 150 | const roster = ["alice", "bob", "carol", "dave"]; | |
| 151 | const three = tally({ | |
| 152 | rule: MAJORITY, | |
| 153 | roster, | |
| 154 | optionIds: OPTIONS, | |
| 155 | ballots: [vote("alice", 1), vote("bob", 1), vote("carol", 1)], | |
| 156 | }); | |
| 157 | expect(three.needed).toBe(3); | |
| 158 | expect(three.decided).toBe(true); | |
| 159 | ||
| 160 | const half = tally({ | |
| 161 | rule: MAJORITY, | |
| 162 | roster, | |
| 163 | optionIds: OPTIONS, | |
| 164 | ballots: [vote("alice", 1), vote("bob", 1)], | |
| 165 | }); | |
| 166 | // Half is not more than half. | |
| 167 | expect(half.decided).toBe(false); | |
| 168 | expect(half.reason).toBe("2 of 4 — a majority needs 3"); | |
| 169 | }); | |
| 170 | ||
| 171 | test("a roster of one decides on one ballot", () => { | |
| 172 | const decided = tally({ | |
| 173 | rule: MAJORITY, | |
| 174 | roster: ["alice"], | |
| 175 | optionIds: OPTIONS, | |
| 176 | ballots: [vote("alice", 3)], | |
| 177 | }); | |
| 178 | expect(decided).toEqual({ | |
| 179 | decided: true, | |
| 180 | winner: 3, | |
| 181 | needed: 1, | |
| 182 | of: 1, | |
| 183 | reason: "1 of 1 — a majority needs 1", | |
| 184 | counts: [ | |
| 185 | { optionId: 1, votes: 0, counting: 0 }, | |
| 186 | { optionId: 2, votes: 0, counting: 0 }, | |
| 187 | { optionId: 3, votes: 1, counting: 1 }, | |
| 188 | ], | |
| 189 | }); | |
| 190 | }); | |
| 191 | ||
| 192 | test("all: everyone must vote and everyone must agree", () => { | |
| 193 | const roster = ["alice", "bob", "carol"]; | |
| 194 | const short = tally({ | |
| 195 | rule: ALL, | |
| 196 | roster, | |
| 197 | optionIds: OPTIONS, | |
| 198 | ballots: [vote("alice", 1), vote("bob", 1)], | |
| 199 | }); | |
| 200 | expect(short.decided).toBe(false); | |
| 201 | expect(short.needed).toBe(3); | |
| 202 | expect(short.reason).toBe("2 of 3 voted — all must agree"); | |
| 203 | ||
| 204 | const agreed = tally({ | |
| 205 | rule: ALL, | |
| 206 | roster, | |
| 207 | optionIds: OPTIONS, | |
| 208 | ballots: [vote("alice", 1), vote("bob", 1), vote("carol", 1)], | |
| 209 | }); | |
| 210 | expect(agreed.decided).toBe(true); | |
| 211 | expect(agreed.winner).toBe(1); | |
| 212 | expect(agreed.reason).toBe("3 of 3 voted — all must agree"); | |
| 213 | ||
| 214 | const split = tally({ | |
| 215 | rule: ALL, | |
| 216 | roster, | |
| 217 | optionIds: OPTIONS, | |
| 218 | ballots: [vote("alice", 1), vote("bob", 1), vote("carol", 2)], | |
| 219 | }); | |
| 220 | expect(split.decided).toBe(false); | |
| 221 | expect(split.reason).toBe("3 of 3 voted — all must agree"); | |
| 222 | ||
| 223 | // A fourth voice, invited or not, is one more that has to agree. | |
| 224 | const grown = tally({ | |
| 225 | rule: ALL, | |
| 226 | roster: [...roster, "dave"], | |
| 227 | optionIds: OPTIONS, | |
| 228 | ballots: [vote("alice", 1), vote("bob", 1), vote("carol", 1)], | |
| 229 | }); | |
| 230 | expect(grown.decided).toBe(false); | |
| 231 | expect(grown.reason).toBe("3 of 4 voted — all must agree"); | |
| 232 | }); | |
| 233 | ||
| 234 | test("named: two of three named, and only theirs count", () => { | |
| 235 | const below = tally({ | |
| 236 | rule: NAMED, | |
| 237 | roster: ROSTER, | |
| 238 | optionIds: OPTIONS, | |
| 239 | ballots: [vote("alice", 1), vote("dave", 1), vote("erin", 1)], | |
| 240 | }); | |
| 241 | expect(below.decided).toBe(false); | |
| 242 | expect(below.of).toBe(3); | |
| 243 | expect(below.needed).toBe(2); | |
| 244 | expect(below.reason).toBe("1 of 3 named — this needs 2"); | |
| 245 | // Everyone's ballot is visible even when only three of them decide. | |
| 246 | expect(below.counts[0]).toEqual({ optionId: 1, votes: 3, counting: 1 }); | |
| 247 | ||
| 248 | const at = tally({ | |
| 249 | rule: NAMED, | |
| 250 | roster: ROSTER, | |
| 251 | optionIds: OPTIONS, | |
| 252 | ballots: [vote("alice", 1), vote("bob", 1)], | |
| 253 | }); | |
| 254 | expect(at.decided).toBe(true); | |
| 255 | expect(at.winner).toBe(1); | |
| 256 | expect(at.reason).toBe("2 of 3 named — this needs 2"); | |
| 257 | ||
| 258 | const above = tally({ | |
| 259 | rule: NAMED, | |
| 260 | roster: ROSTER, | |
| 261 | optionIds: OPTIONS, | |
| 262 | ballots: [vote("alice", 2), vote("bob", 2), vote("carol", 2)], | |
| 263 | }); | |
| 264 | expect(above.decided).toBe(true); | |
| 265 | expect(above.winner).toBe(2); | |
| 266 | expect(above.reason).toBe("3 of 3 named — this needs 2"); | |
| 267 | }); | |
| 268 | ||
| 269 | test("a ballot from outside the people who count never decides", () => { | |
| 270 | const strangers = tally({ | |
| 271 | rule: MAJORITY, | |
| 272 | roster: ["alice", "bob", "carol"], | |
| 273 | optionIds: OPTIONS, | |
| 274 | ballots: [ | |
| 275 | vote("alice", 1), | |
| 276 | vote("mallory", 1), | |
| 277 | vote("trudy", 1), | |
| 278 | vote("eve", 1), | |
| 279 | ], | |
| 280 | }); | |
| 281 | expect(strangers.decided).toBe(false); | |
| 282 | expect(strangers.reason).toBe("1 of 3 — a majority needs 2"); | |
| 283 | expect(strangers.counts[0]).toEqual({ optionId: 1, votes: 4, counting: 1 }); | |
| 284 | ||
| 285 | // Under a named rule, the roster is not the electorate either. | |
| 286 | const unnamed = tally({ | |
| 287 | rule: NAMED, | |
| 288 | roster: ROSTER, | |
| 289 | optionIds: OPTIONS, | |
| 290 | ballots: [vote("dave", 1), vote("erin", 1), vote("frank", 1)], | |
| 291 | }); | |
| 292 | expect(unnamed.decided).toBe(false); | |
| 293 | expect(unnamed.reason).toBe("0 of 3 named — this needs 2"); | |
| 294 | }); | |
| 295 | ||
| 296 | test("a tie decides nothing", () => { | |
| 297 | const tied = tally({ | |
| 298 | rule: { kind: "named", who: ["alice", "bob", "carol", "dave"], needed: 2 }, | |
| 299 | roster: ROSTER, | |
| 300 | optionIds: OPTIONS, | |
| 301 | ballots: [ | |
| 302 | vote("alice", 1), | |
| 303 | vote("bob", 1), | |
| 304 | vote("carol", 2), | |
| 305 | vote("dave", 2), | |
| 306 | ], | |
| 307 | }); | |
| 308 | expect(tied.decided).toBe(false); | |
| 309 | expect(tied.winner).toBeUndefined(); | |
| 310 | expect(tied.reason).toBe("2 of 4 named — this needs 2"); | |
| 311 | }); | |
| 312 | ||
| 313 | test("an empty roster decides nothing under any rule", () => { | |
| 314 | for (const rule of [MAJORITY, ALL] as Rule[]) { | |
| 315 | const empty = tally({ | |
| 316 | rule, | |
| 317 | roster: [], | |
| 318 | optionIds: OPTIONS, | |
| 319 | ballots: [vote("alice", 1), vote("bob", 1)], | |
| 320 | }); | |
| 321 | expect(empty.decided).toBe(false); | |
| 322 | expect(empty.of).toBe(0); | |
| 323 | expect(empty.counts).toEqual([ | |
| 324 | { optionId: 1, votes: 2, counting: 0 }, | |
| 325 | { optionId: 2, votes: 0, counting: 0 }, | |
| 326 | { optionId: 3, votes: 0, counting: 0 }, | |
| 327 | ]); | |
| 328 | } | |
| 329 | expect( | |
| 330 | tally({ rule: MAJORITY, roster: [], optionIds: OPTIONS, ballots: [] }) | |
| 331 | .reason, | |
| 332 | ).toBe("0 of 0 — a majority needs 1"); | |
| 333 | expect( | |
| 334 | tally({ rule: ALL, roster: [], optionIds: OPTIONS, ballots: [] }).reason, | |
| 335 | ).toBe("0 of 0 voted — all must agree"); | |
| 336 | }); | |
| 337 | ||
| 338 | test("no options, no ballots, no decision", () => { | |
| 339 | expect( | |
| 340 | tally({ rule: MAJORITY, roster: ROSTER, optionIds: [], ballots: [] }), | |
| 341 | ).toEqual({ | |
| 342 | decided: false, | |
| 343 | counts: [], | |
| 344 | needed: 4, | |
| 345 | of: 7, | |
| 346 | reason: "0 of 7 — a majority needs 4", | |
| 347 | }); | |
| 348 | }); | |
| 349 | ||
| 350 | test("one ballot per person: the last one counts, and an unknown option is ignored", () => { | |
| 351 | const changed = tally({ | |
| 352 | rule: MAJORITY, | |
| 353 | roster: ["alice", "bob", "carol"], | |
| 354 | optionIds: OPTIONS, | |
| 355 | ballots: [ | |
| 356 | vote("alice", 1), | |
| 357 | vote("bob", 1), | |
| 358 | vote("alice", 2), | |
| 359 | vote("carol", 99), | |
| 360 | ], | |
| 361 | }); | |
| 362 | expect(changed.decided).toBe(false); | |
| 363 | expect(changed.counts).toEqual([ | |
| 364 | { optionId: 1, votes: 1, counting: 1 }, | |
| 365 | { optionId: 2, votes: 1, counting: 1 }, | |
| 366 | { optionId: 3, votes: 0, counting: 0 }, | |
| 367 | ]); | |
| 368 | expect(changed.reason).toBe("1 of 3 — a majority needs 2"); | |
| 369 | }); | |
| 370 | ||
| 371 | test("a repeated person on the roster is one person", () => { | |
| 372 | const doubled = tally({ | |
| 373 | rule: MAJORITY, | |
| 374 | roster: ["alice", "alice", "bob", "carol"], | |
| 375 | optionIds: OPTIONS, | |
| 376 | ballots: [vote("alice", 1), vote("bob", 1)], | |
| 377 | }); | |
| 378 | expect(doubled.of).toBe(3); | |
| 379 | expect(doubled.needed).toBe(2); | |
| 380 | expect(doubled.decided).toBe(true); | |
| 381 | }); |
src/quorum.ts added +193
| @@ -0,0 +1,193 @@ | ||
| 1 | /** | |
| 2 | * When is a proposal decided, and by what. Three rules over plain data: | |
| 3 | * a roster of people, a list of option ids, a list of ballots. No database, | |
| 4 | * no HTTP, no identity — the caller reads the rows and hands over arrays, | |
| 5 | * so a tally is reproducible from its inputs and testable without either. | |
| 6 | * | |
| 7 | * A rule is stored as one line of text, because that is what fits in a | |
| 8 | * column and in a form field: | |
| 9 | * | |
| 10 | * majority more than half the roster on one option | |
| 11 | * all everyone has voted and everyone agrees | |
| 12 | * named:alice,bob,carol:2 2 of those three, and nobody else counts | |
| 13 | * | |
| 14 | * `parseRule` is the only thing in the app that reads that text. It never | |
| 15 | * throws and returns `undefined` for anything it does not recognise, so the | |
| 16 | * text can come straight from a person. Everything `tally` says back is | |
| 17 | * integers and sentences this file wrote: `reason` is safe to print. | |
| 18 | */ | |
| 19 | ||
| 20 | /** One ballot: a person and the option they chose. The last one a person cast is the one that counts. */ | |
| 21 | export interface Ballot { | |
| 22 | readonly voter: string; | |
| 23 | readonly optionId: number; | |
| 24 | } | |
| 25 | ||
| 26 | export type Rule = | |
| 27 | | { readonly kind: "majority" } | |
| 28 | | { readonly kind: "all" } | |
| 29 | | { | |
| 30 | readonly kind: "named"; | |
| 31 | readonly who: readonly string[]; | |
| 32 | readonly needed: number; | |
| 33 | }; | |
| 34 | ||
| 35 | /** The three rules, for a page to offer and a person to read. */ | |
| 36 | export const RULES = [ | |
| 37 | { | |
| 38 | kind: "majority", | |
| 39 | label: "Majority", | |
| 40 | description: "More than half of the group on one option decides it.", | |
| 41 | }, | |
| 42 | { | |
| 43 | kind: "all", | |
| 44 | label: "Everyone", | |
| 45 | description: "Every member of the group has voted and they all agree.", | |
| 46 | }, | |
| 47 | { | |
| 48 | kind: "named", | |
| 49 | label: "A named few", | |
| 50 | description: | |
| 51 | "A set number of named people agree on one option; other ballots do not count.", | |
| 52 | }, | |
| 53 | ] as const; | |
| 54 | ||
| 55 | /** A person's id is the forge's `person_id`, never a display name. */ | |
| 56 | const NAME = /^[A-Za-z0-9_.-]+$/; | |
| 57 | const NAME_MAX = 64; | |
| 58 | const NAMES_MAX = 32; | |
| 59 | /** Longer than any rule this file can write, so parsing is bounded work. */ | |
| 60 | const TEXT_MAX = 2048; | |
| 61 | ||
| 62 | /** | |
| 63 | * Read stored rule text. Exact text only — no trimming, no case folding — | |
| 64 | * so the text that parses is the text to store. `undefined` means refused: | |
| 65 | * an unknown kind, no names, a name that is too long or has a character | |
| 66 | * outside `[A-Za-z0-9_.-]`, more than 32 names, a repeated name, or a | |
| 67 | * `needed` that is not a whole number from 1 to the number of names. | |
| 68 | */ | |
| 69 | export function parseRule(text: unknown): Rule | undefined { | |
| 70 | if (typeof text !== "string" || text.length > TEXT_MAX) return undefined; | |
| 71 | if (text === "majority") return { kind: "majority" }; | |
| 72 | if (text === "all") return { kind: "all" }; | |
| 73 | ||
| 74 | const parts = text.split(":"); | |
| 75 | if (parts.length !== 3) return undefined; | |
| 76 | const [kind, list, needed] = parts as [string, string, string]; | |
| 77 | if (kind !== "named") return undefined; | |
| 78 | ||
| 79 | const who = list.split(","); | |
| 80 | if (who.length > NAMES_MAX) return undefined; | |
| 81 | if ( | |
| 82 | who.some((name) => name.length > NAME_MAX || !NAME.test(name)) || | |
| 83 | new Set(who).size !== who.length | |
| 84 | ) | |
| 85 | return undefined; | |
| 86 | ||
| 87 | if (!/^(?:0|[1-9][0-9]*)$/.test(needed)) return undefined; | |
| 88 | const count = Number(needed); | |
| 89 | if (count < 1 || count > who.length) return undefined; | |
| 90 | return { kind: "named", who, needed: count }; | |
| 91 | } | |
| 92 | ||
| 93 | /** The stored text for a rule, so a caller can keep one canonical spelling. */ | |
| 94 | export function formatRule(rule: Rule): string { | |
| 95 | return rule.kind === "named" | |
| 96 | ? `named:${rule.who.join(",")}:${rule.needed}` | |
| 97 | : rule.kind; | |
| 98 | } | |
| 99 | ||
| 100 | export interface TallyInput { | |
| 101 | readonly rule: Rule; | |
| 102 | /** Everyone who counts toward a quorum, by person id. */ | |
| 103 | readonly roster: readonly string[]; | |
| 104 | readonly ballots: readonly Ballot[]; | |
| 105 | /** Every option on the proposal, in the order a page shows them. */ | |
| 106 | readonly optionIds: readonly number[]; | |
| 107 | } | |
| 108 | ||
| 109 | export interface OptionCount { | |
| 110 | readonly optionId: number; | |
| 111 | /** Every ballot for this option, whoever cast it. */ | |
| 112 | readonly votes: number; | |
| 113 | /** Only the ballots that count toward deciding. */ | |
| 114 | readonly counting: number; | |
| 115 | } | |
| 116 | ||
| 117 | export interface Tally { | |
| 118 | readonly decided: boolean; | |
| 119 | readonly winner?: number; | |
| 120 | /** One row per option id, zero included, in the order they were given. */ | |
| 121 | readonly counts: readonly OptionCount[]; | |
| 122 | /** How many counting ballots one option needs. */ | |
| 123 | readonly needed: number; | |
| 124 | /** How many people the rule is measured against. */ | |
| 125 | readonly of: number; | |
| 126 | /** One sentence, integers only, safe to print as it stands. */ | |
| 127 | readonly reason: string; | |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * Count the ballots and say whether the rule is met. | |
| 132 | * | |
| 133 | * `majority` and `all` are measured against the roster; a `named` rule is | |
| 134 | * measured against its names, and the caller is the one that keeps those | |
| 135 | * names within the roster. Either way a ballot from outside the people the | |
| 136 | * rule measures shows up in `counts` and never moves `decided`, a tie | |
| 137 | * decides nothing, and no one decides an empty electorate. | |
| 138 | */ | |
| 139 | export function tally(input: TallyInput): Tally { | |
| 140 | const { rule, ballots } = input; | |
| 141 | const optionIds = unique(input.optionIds); | |
| 142 | const roster = unique(input.roster); | |
| 143 | const electorate = new Set(rule.kind === "named" ? rule.who : roster); | |
| 144 | const of = electorate.size; | |
| 145 | const needed = | |
| 146 | rule.kind === "majority" | |
| 147 | ? Math.floor(of / 2) + 1 | |
| 148 | : rule.kind === "all" | |
| 149 | ? of | |
| 150 | : rule.needed; | |
| 151 | ||
| 152 | // One ballot per person: the last one they cast for an option that exists. | |
| 153 | const chosen = new Map<string, number>(); | |
| 154 | const options = new Set(optionIds); | |
| 155 | for (const ballot of ballots) | |
| 156 | if (options.has(ballot.optionId)) chosen.set(ballot.voter, ballot.optionId); | |
| 157 | ||
| 158 | const votes = new Map<number, number>(optionIds.map((id) => [id, 0])); | |
| 159 | const counting = new Map<number, number>(optionIds.map((id) => [id, 0])); | |
| 160 | let voted = 0; | |
| 161 | for (const [voter, optionId] of chosen) { | |
| 162 | votes.set(optionId, votes.get(optionId)! + 1); | |
| 163 | if (electorate.has(voter)) { | |
| 164 | counting.set(optionId, counting.get(optionId)! + 1); | |
| 165 | voted += 1; | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | const counts: OptionCount[] = optionIds.map((optionId) => ({ | |
| 170 | optionId, | |
| 171 | votes: votes.get(optionId)!, | |
| 172 | counting: counting.get(optionId)!, | |
| 173 | })); | |
| 174 | ||
| 175 | const top = counts.reduce((most, c) => Math.max(most, c.counting), 0); | |
| 176 | const reached = needed >= 1 ? counts.filter((c) => c.counting >= needed) : []; | |
| 177 | // A tie reaches nothing: two options over the line decide as little as none. | |
| 178 | const winner = | |
| 179 | of > 0 && reached.length === 1 ? reached[0]!.optionId : undefined; | |
| 180 | ||
| 181 | const reason = | |
| 182 | rule.kind === "majority" | |
| 183 | ? `${top} of ${of} — a majority needs ${needed}` | |
| 184 | : rule.kind === "all" | |
| 185 | ? `${voted} of ${of} voted — all must agree` | |
| 186 | : `${top} of ${of} named — this needs ${needed}`; | |
| 187 | ||
| 188 | return winner === undefined | |
| 189 | ? { decided: false, counts, needed, of, reason } | |
| 190 | : { decided: true, winner, counts, needed, of, reason }; | |
| 191 | } | |
| 192 | ||
| 193 | const unique = <T>(values: readonly T[]): T[] => [...new Set(values)]; |