"Do you agree with Jev?": the listener picks which of two songs is more
art, and the page counts how often that matches the art critic's recorded
scores (critic.mjs, tools/critic). The listener's own tally lives in
localStorage; each vote is also sent to the site (sendVote,
worker/src/votes.ts), which only counts it against its pair and pick, so
nix run .#agreement can say how often listeners agree with the critic.
A vote records the pair and the pick, not Jev's scores, so agreement is always against the critic as it stands: a rescore moves it. A pair Jev scores equally has no Jev preference and is left out of the count.
12export const AGREE_KEY = 'jevstrudel.agree';
Songs Jev has scored.
18export const scored = (songs) => songs.filter((s) => typeof artOf(s) === 'number');
The next pair to ask about: among the pairs of scored songs, one the
listener has voted on least; random in [0, 1) picks among those, and
which song shows first.
23export function nextPair(songs, votes, random = Math.random) { 24 const pool = scored(songs); 25 if (pool.length < 2) return null; 26 const count = new Map(); 27 for (const v of votes) count.set(key(v.a, v.b), (count.get(key(v.a, v.b)) ?? 0) + 1); 28 let fewest = Infinity; 29 let pairs = []; 30 for (let i = 0; i < pool.length; i++) { 31 for (let j = i + 1; j < pool.length; j++) { 32 const n = count.get(key(pool[i].id, pool[j].id)) ?? 0; 33 if (n < fewest) [fewest, pairs] = [n, []]; 34 if (n === fewest) pairs.push([pool[i], pool[j]]); 35 } 36 } 37 const pair = pairs[Math.floor(random() * pairs.length)]; 38 return random() < 0.5 ? pair : [pair[1], pair[0]]; 39}
Jev's preference between two songs: the id it scores higher, or null.
How often the listener's picks match Jev's, against the current scores.
49export function agreement(songs, votes) { 50 const byId = new Map(songs.map((s) => [s.id, s])); 51 let agreed = 0; 52 let counted = 0; 53 for (const v of votes) { 54 const jev = jevPrefers(byId.get(v.a), byId.get(v.b)); 55 if (!jev) continue; 56 counted++; 57 if (jev === v.pick) agreed++; 58 } 59 return { agreed, counted, share: counted ? agreed / counted : null, votes: votes.length }; 60}
62export const VOTES = '/jev/votes';
Counts one vote on the site. Fire and forget: the page's own tally never
waits for it or depends on it, and a failed send is just not counted.
keepalive lets it finish if the tab closes straight after the click.
67export function sendVote({ a, b, pick }, fetchImpl = globalThis.fetch) { 68 try { 69 fetchImpl?.(VOTES, { 70 method: 'POST', 71 headers: { 'Content-Type': 'application/json' }, 72 body: JSON.stringify({ a, b, pick }), 73 keepalive: true, 74 })?.catch?.(() => {}); 75 } catch { 76 // no fetch: not counted 77 } 78}
80export function readVotes(storage) { 81 try { 82 const votes = JSON.parse(storage?.getItem(AGREE_KEY) ?? '[]'); 83 return Array.isArray(votes) ? votes.filter((v) => v && v.a && v.b && (v.pick === v.a || v.pick === v.b)) : []; 84 } catch { 85 return []; 86 } 87} 88 89export function writeVotes(storage, votes) { 90 try { 91 storage?.setItem(AGREE_KEY, JSON.stringify(votes)); 92 } catch { 93 // no storage: the count lasts until the tab closes 94 } 95}
── everyone's votes (GET /jev/votes/summary) ─────────
98export const SUMMARY = '/jev/votes/summary';
The site's counts ([{ a, b, pick, n }]) against the songs' current
scores: overall, and per song over the pairs it was in, most-voted
first. A vote agrees when its pick is the song the critic scores higher;
a pair the critic scores equally has no preference and is left out (as
in agreement), and so is a vote on a song no longer here or unscored.
Per song, a vote counts for both songs in its pair. nix run .#agreement
(tools/agreement) prints it; the results view (VoteResults.jsx) shows it.
107export function agreementReport(songs, counts) { 108 const byId = new Map(songs.map((s) => [s.id, s])); 109 const overall = { agreed: 0, counted: 0, ties: 0, unknown: 0, votes: 0 }; 110 const per = new Map(); 111 const tally = (id) => { 112 if (!per.has(id)) per.set(id, { id, art: artOf(byId.get(id)), agreed: 0, counted: 0 }); 113 return per.get(id); 114 }; 115 for (const { a, b, pick, n } of counts) { 116 overall.votes += n; 117 const [sa, sb] = [byId.get(a), byId.get(b)]; 118 if (typeof artOf(sa) !== 'number' || typeof artOf(sb) !== 'number' || (pick !== a && pick !== b)) { 119 overall.unknown += n; 120 continue; 121 } 122 const jev = jevPrefers(sa, sb); 123 if (!jev) { 124 overall.ties += n; 125 continue; 126 } 127 const agreed = jev === pick ? n : 0; 128 overall.agreed += agreed; 129 overall.counted += n; 130 for (const id of [a, b]) { 131 const t = tally(id); 132 t.agreed += agreed; 133 t.counted += n; 134 } 135 } 136 const share = (t) => ({ ...t, share: t.counted ? t.agreed / t.counted : null }); 137 return { 138 overall: share(overall), 139 songs: [...per.values()].map(share).sort((x, y) => y.counted - x.counted || x.id.localeCompare(y.id)), 140 }; 141}
Listeners' ranking from pairwise votes: a Bradley–Terry model, in which
song i beats song j with probability s_i / (s_i + s_j), fitted by
Hunter's MM algorithm (2004). Each song also gets prior wins and
prior losses against a virtual average song of strength 1: that keeps
a song with a handful of votes near the middle rather than at an
extreme, and gives an unbeaten song a finite strength. Votes on a song
not in ids are left out. Returns id → { strength, wins, games }, where
strength / (strength + 1) is the chance to beat an average song.
151export const PRIOR = 1; 152export function bradleyTerry(ids, counts, { prior = PRIOR, iterations = 1000, tolerance = 1e-10 } = {}) { 153 const known = new Set(ids); 154 const wins = new Map(ids.map((id) => [id, 0])); 155 const games = new Map(ids.map((id) => [id, new Map()])); // id → other → votes on the pair 156 for (const { a, b, pick, n } of counts) { 157 if (!known.has(a) || !known.has(b) || a === b || (pick !== a && pick !== b) || !(n > 0)) continue; 158 wins.set(pick, wins.get(pick) + n); 159 games.get(a).set(b, (games.get(a).get(b) ?? 0) + n); 160 games.get(b).set(a, (games.get(b).get(a) ?? 0) + n); 161 } 162 let s = new Map(ids.map((id) => [id, 1])); 163 for (let round = 0; round < iterations; round++) { 164 const next = new Map(); 165 let moved = 0; 166 for (const id of ids) { 167 let denominator = (2 * prior) / (s.get(id) + 1); 168 for (const [other, n] of games.get(id)) denominator += n / (s.get(id) + s.get(other)); 169 const value = denominator > 0 ? (wins.get(id) + prior) / denominator : 1; 170 moved = Math.max(moved, Math.abs(Math.log(value / s.get(id)))); 171 next.set(id, value); 172 } 173 s = next; 174 if (moved < tolerance) break; 175 } 176 const played = (id) => [...games.get(id).values()].reduce((x, y) => x + y, 0); 177 return new Map(ids.map((id) => [id, { strength: s.get(id), wins: wins.get(id), games: played(id) }])); 178}
What the results view shows: every song Jev has scored, ranked by listeners (Bradley–Terry) beside Jev's own rank by art; the overall agreement; and the pairs where listeners' majority outvotes Jev's preference, the widest margins first, with their counts.
184export function voteResults(songs, counts) { 185 const pool = scored(songs); 186 const fit = bradleyTerry( 187 pool.map((s) => s.id), 188 counts, 189 ); 190 const jevRank = new Map([...pool].sort((x, y) => artOf(y) - artOf(x)).map((s, i) => [s.id, i + 1])); 191 const ranked = pool 192 .map((s) => { 193 const f = fit.get(s.id); 194 return { 195 id: s.id, 196 title: s.title ?? s.id, 197 art: artOf(s), 198 jevRank: jevRank.get(s.id), 199 ...f, 200 beatsAverage: f.strength / (f.strength + 1), 201 }; 202 }) 203 .sort((x, y) => y.strength - x.strength || y.games - x.games || x.id.localeCompare(y.id)) 204 .map((row, i) => ({ ...row, listenerRank: i + 1 })); 205 206 const byId = new Map(pool.map((s) => [s.id, s])); 207 const pairs = new Map(); // "a|b" (sorted) → { a, b, votes: { [id]: n } } 208 for (const { a, b, pick, n } of counts) { 209 if (!byId.has(a) || !byId.has(b) || a === b || (pick !== a && pick !== b) || !(n > 0)) continue; 210 const [x, y] = [a, b].sort(); 211 const k = `${x}|${y}`; 212 if (!pairs.has(k)) pairs.set(k, { votes: { [x]: 0, [y]: 0 }, a: x, b: y }); 213 pairs.get(k).votes[pick] += n; 214 } 215 const disagreements = []; 216 for (const { a, b, votes } of pairs.values()) { 217 const jev = jevPrefers(byId.get(a), byId.get(b)); 218 if (!jev) continue; 219 const listeners = jev === a ? b : a; 220 const against = votes[listeners]; 221 const withJev = votes[jev]; 222 if (against > withJev) disagreements.push({ jev, listeners, against, withJev, votes: against + withJev }); 223 } 224 disagreements.sort((x, y) => y.against - y.withJev - (x.against - x.withJev) || y.votes - x.votes); 225 return { 226 overall: agreementReport(songs, counts).overall, 227 songs: ranked, 228 disagreements, 229 }; 230}