jevstrudel.git / website / src / jev / agree.mjs

"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';
14const key = (a, b) => [a, b].sort().join('|');
15const artOf = (song) => song?.art?.p;

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.

42export function jevPrefers(a, b) {
43  const [pa, pb] = [artOf(a), artOf(b)];
44  if (typeof pa !== 'number' || typeof pb !== 'number' || pa === pb) return null;
45  return pa > pb ? a.id : b.id;
46}

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}