Public: anyone can read this; the crew can change it.

Three quorum rules as one pure module: majority, all, a named few #11

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/quorum:ledger/3-three-quorum-rules-as-one-pure-module-ma → main 1 commit fork

  • Merged. ledger/3-three-quorum-rules-as-one-pure-module-ma was merged into main as 9915baf.

Review

Sign in to review this change.
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)];

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs