RankedVote
Counts ranked ballots by instant runoff, round by round, showing each transfer and exhausted ballot, with a stated rule for breaking ties.
Demo
Where should the capital be?
A tie for last: whoever had fewest in the latest earlier round where they differed goes out; if they were level all along, the one listed last.
Knoxville wins in round 3, with 58 of the 100 ballots in play (58.0%). 0 ballots exhausted.
Round 1
100 ballots in play, 51 to win- Memphis42 (42.0%)
- Nashville26 (26.0%)
- Knoxville17 (17.0%)
- Chattanooga15 (15.0%)
Chattanooga goes out: 15 to Knoxville.
Round 2
100 ballots in play, 51 to win- Memphis42 (42.0%)
- Knoxville32 (32.0%)+15
- Nashville26 (26.0%)
Nashville goes out: 26 to Knoxville.
Round 3
100 ballots in play, 51 to win- Knoxville58 (58.0%)+26
- Memphis42 (42.0%)
Club chair
A tie for last: whoever had fewest first preferences goes out; if they were level, the one listed last.
Ada wins in round 3, with 7 of the 7 ballots in play (100.0%). 3 ballots exhausted. The tie rule decided this.
Round 1
10 ballots in play, 6 to win- Ada4 (40.0%)
- Bo3 (30.0%)
- Cy3 (30.0%)
Bo and Cy tied on 3. Cy goes out: 1 to Bo and 2 exhausted. Broken by listed last.
Round 2
8 ballots in play, 5 to win, 2 exhausted- Ada4 (50.0%)
- Bo4 (50.0%)+1
Ada and Bo tied on 4. Bo goes out: 3 to Ada and 1 exhausted. Broken by fewest first preferences.
Round 3
7 ballots in play, 4 to win, 3 exhausted- Ada7 (100.0%)+3
Change it to see on_change.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/ranked_vote.qweimport ui/theme.qwe use theme_cssuse ui/ranked_vote.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
let ranked_vote_demo_ballots = "42: Memphis > Nashville > Chattanooga > Knoxville\n26: Nashville > Chattanooga > Knoxville > Memphis\n15: Chattanooga > Knoxville > Nashville > Memphis\n17: Knoxville > Chattanooga > Nashville > Memphis"component RankedVoteDemoLive() { state seen = null <div.demo-stack> <RankedVote id="ranked-vote-demo-club" title="Club chair" name="club" tie_rule="first" candidates={["Ada", "Bo", "Cy"]} ballots={[{ ranking: ["Ada", "Bo"], count: 4 }, { ranking: ["Bo", "Ada"], count: 3 }, { ranking: ["Cy"], count: 2 }, ["Cy", "Bo"]]} on_change={(r) => { seen = r }}/> <p.demo-note>{seen == null ? "Change it to see on_change." : `on_change: ${seen.winner ?? "no one"} in ${seen.rounds} rounds, ${seen.exhausted} exhausted.`}</p> </div>}view RankedVoteDemo() = <div.demo-stack> <RankedVote id="ranked-vote-demo" title="Where should the capital be?" ballots={ranked_vote_demo_ballots}/> <RankedVoteDemoLive/> </div>Source
// RankedVote: ranked ballots counted by instant runoff, round by round. Each round// every ballot counts for the first name on it still in the race. A name with more// than half of the ballots still in play wins; else the name with the fewest goes out// and its ballots move to their next choice, which the round shows, name by name. A// ballot with no choice left is exhausted: it is counted, and leaves the ballots in// play, so the winning line can fall. A tie for last is broken by a rule stated beside// the count, and the result says when that rule decided it.//// <RankedVote candidates={["Ada", "Bo", "Cy"]} ballots="12: Ada > Bo\n9: Bo > Cy\n7: Cy > Bo"/>// <RankedVote ballots={[{ ranking: ["Ada", "Bo"], count: 12 }, ["Cy", "Bo"]]} tie_rule="first"// on_change={(r) => { winner = r.winner }}/>//// ballots: a ballot a line, as text ("Ada > Bo > Cy", with "12: " or "12 x " in front// for that many alike, commas work too), or a list of ballots, each a list of names// or { ranking, count }. candidates: the names, a list or text with commas; left out,// the names on the ballots, first seen first. A name not among the candidates, or// given twice on a ballot, is left off that ballot and reported by line. tie_rule:// "back" (the default: whoever had fewest in the latest earlier round where they// differed goes), "first" (fewest first preferences goes) or "listed" (the one// listed last goes); the first two fall back on "listed", so a count always ends.// title: the heading ("Ranked vote"). id: a prefix for its fields' ids ("ranked-vote"),// unique on the page. The fields are named candidates, ballots and tie_rule; name: a// prefix for those names. on_change(r): inside one of your components, given// { winner, rounds, ballots, exhausted, by_tie } after each change, winner null// when there is no count.//// ranked_vote_read(text, candidates) and ranked_vote_count(ballots, names, rule) are// the work, for a program's own use. It reads 2,000 lines and 50 candidates, and says so.import ui/theme.qwe use ui_clslet ranked_vote_css = css`.ui-rcv { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; min-width: 0; max-width: 680px; padding: 20px 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-rcv .ui-rcv-title { display: block; margin: 0; font-size: 16px; font-weight: 650; }.ui-rcv-field { display: grid; gap: 5px; min-width: 0; font-size: 13px; font-weight: 500; }.ui-rcv input, .ui-rcv select, .ui-rcv textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.5 var(--ui-font); }.ui-rcv input, .ui-rcv select { height: 38px; }.ui-rcv textarea { min-height: 130px; padding: 8px 10px; resize: vertical; }.ui-rcv input:focus-visible, .ui-rcv select:focus-visible, .ui-rcv textarea:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-rcv-rule { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-rcv-problems { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; color: var(--ui-danger); font-size: 13px; }.ui-rcv-result { margin: 0; padding: 10px 14px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); font-weight: 600; }.ui-rcv-rounds { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }.ui-rcv-round { display: grid; gap: 8px; min-width: 0; padding: 12px 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); }.ui-rcv-round-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 12px; margin: 0; }.ui-rcv .ui-rcv-round-name { margin: 0; font-size: 14px; font-weight: 650; }.ui-rcv-need { color: var(--ui-muted); font-size: 12px; }.ui-rcv-tally { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }.ui-rcv-line { display: grid; grid-template-columns: minmax(0, 120px) minmax(0, 1fr) auto; gap: 8px; align-items: center; font-variant-numeric: tabular-nums; }.ui-rcv-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-rcv-track { height: 10px; overflow: hidden; border-radius: 999px; background: var(--ui-subtle); }.ui-rcv-fill { height: 100%; border-radius: 999px; background: var(--ui-accent); }.ui-rcv-gone .ui-rcv-fill { background: var(--ui-danger); }.ui-rcv-won .ui-rcv-fill { background: var(--ui-success); }.ui-rcv-count { font-weight: 600; }.ui-rcv-gain { margin-left: 4px; color: var(--ui-success); font-size: 12px; font-weight: 500; }.ui-rcv-move { margin: 0; color: var(--ui-muted); font-size: 13px; }`let ranked_vote_rules = [ { id: "back", label: "Look back through earlier rounds", text: "A tie for last: whoever had fewest in the latest earlier round where they differed goes out; if they were level all along, the one listed last." }, { id: "first", label: "Fewest first preferences", text: "A tie for last: whoever had fewest first preferences goes out; if they were level, the one listed last." }, { id: "listed", label: "Listed last", text: "A tie for last: the one listed last goes out." },]let ranked_vote_most_lines = 2000let ranked_vote_most_names = 50fn ranked_vote_names(given) { let list_of = type_of(given) == "list" ? given : split(str(given ?? ""), ",") list_of | map((x) => trim(str(x))) | filter((x) => x != "")}fn ranked_vote_tally(ballots: list, alive: list, size: int) { let votes = for i in 0..size => 0 let gone = 0 for b in ballots { let at = ranked_vote_next(b.ranking, alive) if at == null { gone += b.count } else { votes = set(votes, at, votes[at] + b.count) } } { votes, exhausted: gone }}// The first choice on a ranking still in the race, or null.fn ranked_vote_next(ranking: list, alive: list) { for c in ranking { if alive[c] { return c } } null}fn ranked_vote_join(xs: list) { let n = len(xs) n < 2 ? join(xs, "") : `${join(slice(xs, 0, n - 1), ", ")} and ${xs[n - 1]}`}// Who of those tied goes out, and why, by the rule.fn ranked_vote_break(tied: list, rule: str, earlier: list, firsts: list) { let left = tied let why = "listed last" if rule == "back" { for r in reverse(range(len(earlier))) { if len(left) > 1 { let low = left | map((c) => earlier[r][c]) | min() let fewer = left | filter((c) => earlier[r][c] == low) if len(fewer) < len(left) { left = fewer why = `fewest in round ${r + 1}` } } } } if rule == "first" { let low = left | map((c) => firsts[c]) | min() let fewer = left | filter((c) => firsts[c] == low) if len(fewer) < len(left) { left = fewer why = "fewest first preferences" } } if len(left) > 1 { why = "listed last" } { out: last(left), why }}// The count: { rounds, winner, by_tie, ballots, exhausted }, with each round// { number, lines, active, exhausted, need, out, moves, to_exhausted, tie }.fn ranked_vote_count(ballots: list, names: list, rule: str = "back") { let size = len(names) let total = ballots | map((b) => b.count) | sum() if total == 0 || size == 0 { return { rounds: [], winner: null, by_tie: false, ballots: total, exhausted: 0 } } let alive = for i in 0..size => true let firsts = ranked_vote_tally(ballots, alive, size).votes let earlier = [] let rounds = [] let winner = null let by_tie = false while winner == null { let t = ranked_vote_tally(ballots, alive, size) let still = range(size) | filter((c) => alive[c]) let active = total - t.exhausted let need = int(floor(active / 2)) + 1 let top = still | map((c) => t.votes[c]) | max() let before = len(earlier) == 0 ? null : earlier[len(earlier) - 1] let order = still | sort_by((c) => -t.votes[c]) let lines = order | map((c) => { name: names[c], votes: t.votes[c], gain: before == null ? 0 : t.votes[c] - before[c], share: active == 0 ? 0m : round(dec(t.votes[c]) * 100 / active, 1, "half_up") }) let round_ = { number: len(rounds) + 1, lines, active, exhausted: t.exhausted, need, out: null, won: null, moves: [], to_exhausted: 0, tie: null } if len(still) == 1 || top >= need { winner = names[order[0]] rounds = rounds | push(set(round_, "won", winner)) } else { let low = still | map((c) => t.votes[c]) | min() let tied = still | filter((c) => t.votes[c] == low) let pick = ranked_vote_break(tied, rule, earlier, firsts) let out = pick.out // The two last in the race level: the rule has chosen the winner. if len(tied) == len(still) { by_tie = true } let after = set(alive, out, false) let moved = for c in 0..size => 0 let lost = 0 for b in ballots { if ranked_vote_next(b.ranking, alive) == out { let at = ranked_vote_next(b.ranking, after) if at == null { lost += b.count } else { moved = set(moved, at, moved[at] + b.count) } } } let moves = range(size) | filter((c) => moved[c] > 0) | sort_by((c) => -moved[c]) | map((c) => { name: names[c], votes: moved[c] }) let tie = len(tied) < 2 ? null : { names: tied | map((c) => names[c]), votes: low, why: pick.why } rounds = rounds | push({ ...round_, out: names[out], moves, to_exhausted: lost, tie }) alive = after } earlier = earlier | push(t.votes) } { rounds, winner, by_tie, ballots: total, exhausted: last(rounds).exhausted }}// One line of text as { count, ranking, problems }: the ranking as indexes into `names`,// found through `index`, which is keyed by small letters.fn ranked_vote_line(line: str, number: int, names: list, index, problems: list) { let t = trim(line) let many = re.find(t, "^(\\d{1,7})\\s*(:|x\\b|×|\\*)\\s*") let count = many == null ? 1 : int(many.groups[0]) let body = many == null ? t : slice(t, many.end, len(t)) let seen = [] let said = problems for raw in re.split(body, "[>,]") { let word = trim(raw) if word != "" { let at = get(index, lower(word), null) if at == null { said = said | push(`Line ${number}: "${slice(word, 0, 40)}" is not a candidate, so it is left off the ballot.`) } else if contains(seen, at) { said = said | push(`Line ${number}: ${names[at]} is ranked twice; the first counts.`) } else { seen = seen | push(at) } } } { count, ranking: seen, problems: said }}// The ballots as text, whatever form they came in.fn ranked_vote_text(ballots) { if type_of(ballots) != "list" { return str(ballots ?? "") } let one = (b) => { let ranking = type_of(b) == "map" ? (b.ranking ?? []) : type_of(b) == "list" ? b : [b] let count = type_of(b) == "map" ? (b.count ?? 1) : 1 `${count == 1 ? "" : `${count}: `}${join(ranking | map((x) => str(x)), " > ")}` } join(ballots | map(one), "\n")}// Text to what the count takes: { names, ballots, problems, spoiled }. Equal rankings// are put together, so a long list costs what its distinct ballots cost.fn ranked_vote_read(text: str, candidates) { let all_lines = split(replace(text, "\r", ""), "\n") let lines = all_lines | take(ranked_vote_most_lines) let problems = len(all_lines) > ranked_vote_most_lines ? [`Only the first ${ranked_vote_most_lines} lines are read.`] : [] let given = ranked_vote_names(candidates) // Without a list of candidates, the ballots name them. let found = len(given) > 0 ? given : lines | map((l) => re.replace(trim(l), "^\\d{1,7}\\s*(:|x\\b|×|\\*)\\s*", "")) | map((l) => re.split(l, "[>,]") | map((w) => trim(w)) | filter((w) => w != "")) | flatten() let names = [] let index = {} for w in found { if !has(index, lower(w)) { index = set(index, lower(w), len(names)) names = names | push(w) } } if len(names) > ranked_vote_most_names { return { names: [], ballots: [], spoiled: 0, problems: [`There are ${len(names)} candidates: ${ranked_vote_most_names} is the most this counts.`] } } let merged = {} let spoiled = 0 for i in 0..len(lines) { if trim(lines[i]) != "" { let b = ranked_vote_line(lines[i], i + 1, names, index, problems) problems = b.problems if len(b.ranking) == 0 { spoiled += b.count } else { let key_ = join(b.ranking, ",") let had = get(merged, key_, null) merged = set(merged, key_, { ranking: b.ranking, count: b.count + (had == null ? 0 : had.count) }) } } } { names, ballots: values(merged), problems, spoiled }}fn ranked_vote_noun(n: int) = n == 1 ? "1 ballot" : `${n} ballots`// What happened at the end of a round, in words.fn ranked_vote_moves(r) { let parts = (r.moves | map((m) => `${m.votes} to ${m.name}`)) + (r.to_exhausted > 0 ? [`${r.to_exhausted} exhausted`] : []) let gone = `${r.out} goes out${len(parts) > 0 ? `: ${ranked_vote_join(parts)}` : ", with no ballots to move"}.` r.tie == null ? gone : `${ranked_vote_join(r.tie.names)} tied on ${r.tie.votes}. ${gone} Broken by ${r.tie.why}.`}fn ranked_vote_summary(c, spoiled: int) { if c.winner == null { return "No ballots to count yet." } let last_round = last(c.rounds) let top = first(last_round.lines) let spoilt = spoiled > 0 ? ` ${ranked_vote_noun(spoiled)} named no candidate and did not count.` : "" let n = len(c.rounds) `${c.winner} wins in round ${n}, with ${top.votes} of the ${last_round.active} ballots in play (${str(top.share)}%). ${ranked_vote_noun(c.exhausted)} exhausted.${spoilt}${c.by_tie ? " The tie rule decided this." : ""}`}component RankedVote(ballots = "", candidates = null, tie_rule: str = "back", title: str = "Ranked vote", id: str = "ranked-vote", name: str? = null, on_change = null, class: str? = null) { style global ranked_vote_css state names_text = type_of(candidates) == "list" ? join(candidates | map((x) => str(x)), ", ") : str(candidates ?? "") state text = ranked_vote_text(ballots) state rule = any(ranked_vote_rules, (r) => r.id == tie_rule) ? tie_rule : "back" let named = (n: str) => name != null ? `${name}_${n}` : n let read = ranked_vote_read(text, names_text) let c = ranked_vote_count(read.ballots, read.names, rule) let rule_text = (ranked_vote_rules | find((r) => r.id == rule)).text let tell = () => { if on_change != null { let r = ranked_vote_read(text, names_text) let k = ranked_vote_count(r.ballots, r.names, rule) on_change({ winner: k.winner, rounds: len(k.rounds), ballots: k.ballots, exhausted: k.exhausted, by_tie: k.by_tie }) } } <section class={ui_cls(["ui-rcv", class])} aria-labelledby={`${id}-title`}> <h3.ui-rcv-title id={`${id}-title`}>{title}</h3> <label.ui-rcv-field> Candidates, with commas between (left empty, the names on the ballots) <input type="text" id={`${id}-names`} name={named("candidates")} value={names_text} autocomplete="off" @input={(e) => { names_text = e.value; tell() }}/> </label> <label.ui-rcv-field> Ballots, one a line, first choice first: Ada > Bo > Cy, or 12: Ada > Bo for twelve alike <textarea id={`${id}-ballots`} name={named("ballots")} value={text} rows="6" spellcheck="false" @input={(e) => { text = e.value; tell() }}></textarea> </label> <label.ui-rcv-field> If there is a tie for last <select id={`${id}-rule`} name={named("tie_rule")} @change={(e) => { rule = e.value; tell() }}> {for r in ranked_vote_rules => <option key={r.id} value={r.id} selected={r.id == rule}>{r.label}</option>} </select> </label> <p.ui-rcv-rule>{rule_text}</p> {len(read.problems) > 0 && <ul.ui-rcv-problems role="alert"> {for p in read.problems | take(8) => <li>{p}</li>} {len(read.problems) > 8 && <li>{`And ${len(read.problems) - 8} more.`}</li>} </ul>} <p.ui-rcv-result aria-live="polite">{ranked_vote_summary(c, read.spoiled)}</p> {len(c.rounds) > 0 && <ol.ui-rcv-rounds> {for r in c.rounds { let n = r.number <li.ui-rcv-round key={n}> <div.ui-rcv-round-head> <h4.ui-rcv-round-name>{`Round ${n}`}</h4> <span.ui-rcv-need>{`${ranked_vote_noun(r.active)} in play, ${r.need} to win${r.exhausted > 0 ? `, ${r.exhausted} exhausted` : ""}`}</span> </div> <ul.ui-rcv-tally> {for l in r.lines { let who = l.name <li key={who} class={ui_cls(["ui-rcv-line", who == r.out && "ui-rcv-gone", who == r.won && "ui-rcv-won"])}> <span.ui-rcv-name title={who}>{who}</span> <span.ui-rcv-track aria-hidden="true"><span.ui-rcv-fill style={`display:block;width:${str(l.share)}%`}/></span> <span><span.ui-rcv-count>{`${l.votes} (${str(l.share)}%)`}</span>{l.gain > 0 && <span.ui-rcv-gain>{`+${l.gain}`}</span>}</span> </li> }} </ul> {r.out != null && <p.ui-rcv-move>{ranked_vote_moves(r)}</p>} </li> }} </ol>} </section>}Related
Thread 0 posts
On RankedVote: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.