From db1f9e44caa584d6504ae041eace29ea505fbd6a Mon Sep 17 00:00:00 2001 From: Jacob Cheatley Date: Tue, 22 Sep 2026 09:28:00 +1200 Subject: [PATCH] Elemental Showdown: score a Matchup from its three sums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One pure function turns a Matchup's count, sum(value) and sum(value²) into the numbers the reveal, the Stats, the Stories and Matchup selection all read: its Effectiveness from the lower-id Element's side, its Confidence, the shrunk mean Vote, the polarisation and the selection weight. The Dirichlet-multinomial posterior behind them, the prior, the cuts and the verdict gate live here by name and nowhere else. A Matchup nobody has voted on needs no special case: the prior alone scores it as faint Neutral. Mirroring turns the lower-id Element's call into the higher-id one's, and the headline reads the Voter's own Vote against the crowd's step once the Matchup has enough Votes to have one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AVEtgjCoEbaNHrYfzyCRNH --- .../elemental-showdown/matchup-score.test.ts | 145 ++++++++++++++++ .../elemental-showdown/matchup-score.ts | 161 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/projects/elemental-showdown/matchup-score.test.ts create mode 100644 src/projects/elemental-showdown/matchup-score.ts diff --git a/src/projects/elemental-showdown/matchup-score.test.ts b/src/projects/elemental-showdown/matchup-score.test.ts new file mode 100644 index 0000000..c011134 --- /dev/null +++ b/src/projects/elemental-showdown/matchup-score.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + headlineFor, + mirrorEffectiveness, + scoreMatchup, +} from "./matchup-score"; +import type { VoteValue } from "./showdown-schema"; + +// The three sums the aggregate returns, built from the Votes that made them so +// a crowd can never be described by sums that disagree. +const crowd = (...votes: VoteValue[]) => ({ + voteCount: votes.length, + valueSum: votes.reduce((total, value) => total + value, 0), + squareSum: votes.reduce((total, value) => total + value * value, 0), +}); + +const votes = (count: number, value: VoteValue): VoteValue[] => + Array.from({ length: count }, () => value); + +describe("scoreMatchup", () => { + it("crosses from Neutral to 2× at the win cut", () => { + // five weak wins against three too-close-to-call: the mean sits on 0.5 + const onTheCut = scoreMatchup(crowd(...votes(5, 1), ...votes(3, 0))); + const belowIt = scoreMatchup(crowd(...votes(5, 1), ...votes(4, 0))); + + expect(onTheCut.meanVote).toBe(0.5); + expect(onTheCut.effectiveness).toBe("2×"); + expect(belowIt.effectiveness).toBe("neutral"); + }); + + it("crosses from 2× to 4× at the crush cut", () => { + const onTheCut = scoreMatchup(crowd(...votes(7, 2), 1)); + const belowIt = scoreMatchup(crowd(...votes(6, 2), ...votes(2, 1))); + + expect(onTheCut.meanVote).toBe(1.5); + expect(onTheCut.effectiveness).toBe("4×"); + expect(belowIt.effectiveness).toBe("2×"); + }); + + it("reads the lower-id Element losing as ½× and ¼×", () => { + expect( + scoreMatchup(crowd(...votes(5, -1), ...votes(3, 0))).effectiveness, + ).toBe("½×"); + expect(scoreMatchup(crowd(...votes(7, -2), -1)).effectiveness).toBe("¼×"); + }); + + it("crosses from Neutral to Controversial at the polarisation cut", () => { + // one Voter each way at the extremes, with one shrugging Voter between them + const onTheCut = scoreMatchup(crowd(-2, 0, 2)); + const belowIt = scoreMatchup(crowd(-2, 0, 0, 2)); + + expect(onTheCut.polarisation).toBe(0.6); + expect(onTheCut.effectiveness).toBe("controversial"); + expect(belowIt.effectiveness).toBe("neutral"); + }); + + it("calls a crowd split between the extremes Controversial", () => { + expect(scoreMatchup(crowd(-2, -2, 2, 2)).effectiveness).toBe( + "controversial", + ); + }); + + it("calls a crowd piled on too close to call Neutral", () => { + expect(scoreMatchup(crowd(...votes(14, 0))).effectiveness).toBe("neutral"); + }); + + it("calls a split crowd with a lean a win rather than Controversial", () => { + const leaning = scoreMatchup(crowd(...votes(4, 2), ...votes(2, -2))); + + expect(leaning.polarisation).toBeGreaterThan(0.6); + expect(leaning.effectiveness).toBe("2×"); + }); + + it("crosses from faint to medium at the medium Confidence cut", () => { + expect(scoreMatchup(crowd(...votes(5, 0))).confidence).toBe("faint"); + expect(scoreMatchup(crowd(...votes(6, 0))).confidence).toBe("medium"); + }); + + it("crosses from medium to solid at the solid Confidence cut", () => { + expect(scoreMatchup(crowd(...votes(13, 0))).confidence).toBe("medium"); + expect(scoreMatchup(crowd(...votes(14, 0))).confidence).toBe("solid"); + }); + + it("scores a Matchup nobody has voted on as faint Neutral", () => { + expect(scoreMatchup(crowd())).toMatchObject({ + effectiveness: "neutral", + confidence: "faint", + voteCount: 0, + }); + }); + + it("weighs a Matchup nobody has voted on above a settled one", () => { + expect(scoreMatchup(crowd()).selectionWeight).toBeGreaterThan( + scoreMatchup(crowd(...votes(14, 0))).selectionWeight, + ); + }); +}); + +describe("mirrorEffectiveness", () => { + it("reads a win from the other Element's side as a loss", () => { + expect(mirrorEffectiveness("2×")).toBe("½×"); + expect(mirrorEffectiveness("4×")).toBe("¼×"); + expect(mirrorEffectiveness("½×")).toBe("2×"); + expect(mirrorEffectiveness("¼×")).toBe("4×"); + }); + + it("reads a Matchup with no winner the same from both sides", () => { + expect(mirrorEffectiveness("neutral")).toBe("neutral"); + expect(mirrorEffectiveness("controversial")).toBe("controversial"); + }); +}); + +describe("headlineFor", () => { + // The crowd's mean sits at +0.71, so its Effectiveness step is a weak win. + const weakWinCrowd = scoreMatchup(crowd(...votes(5, 1))); + + it("calls the only Vote on a Matchup the first to call it", () => { + expect(headlineFor(scoreMatchup(crowd(2)), 2)).toBe("first"); + }); + + it("calls the Votes below the verdict gate early days", () => { + expect(headlineFor(scoreMatchup(crowd(...votes(4, 1))), 1)).toBe("early"); + }); + + it("sides the Voter with the crowd when their Vote is its step", () => { + expect(headlineFor(weakWinCrowd, 1)).toBe("with"); + }); + + it("puts the Voter close to the crowd one step either side of it", () => { + expect(headlineFor(weakWinCrowd, 0)).toBe("close"); + expect(headlineFor(weakWinCrowd, 2)).toBe("close"); + }); + + it("puts the Voter against the crowd two steps or more from it", () => { + expect(headlineFor(weakWinCrowd, -1)).toBe("against"); + expect(headlineFor(weakWinCrowd, -2)).toBe("against"); + }); + + it("says the crowd is split on a Controversial Matchup", () => { + const split = scoreMatchup(crowd(...votes(3, -2), ...votes(3, 2))); + + expect(headlineFor(split, 2)).toBe("split"); + expect(headlineFor(split, -2)).toBe("split"); + }); +}); diff --git a/src/projects/elemental-showdown/matchup-score.ts b/src/projects/elemental-showdown/matchup-score.ts new file mode 100644 index 0000000..9651295 --- /dev/null +++ b/src/projects/elemental-showdown/matchup-score.ts @@ -0,0 +1,161 @@ +import type { VoteValue } from "./showdown-schema"; + +// What the crowd's Votes on one Matchup add up to. Nothing here may import +// server code: the reveal, the Stats and the Stories all read these values in +// the browser. + +// The crowd's call on one Matchup, read from one Element's side. What one +// Element reads as 2×, the other reads as ½×. +export type Effectiveness = + | "4×" + | "2×" + | "neutral" + | "controversial" + | "½×" + | "¼×"; + +// How sure the crowd is of that call, given how many Votes it has and how much +// they agree. +export type Confidence = "solid" | "medium" | "faint"; + +// What the reveal says about the Voter's own Vote once it is counted. +export type HeadlineKind = + | "first" + | "early" + | "split" + | "with" + | "close" + | "against"; + +// One Matchup's Votes as the aggregate sums them: `count`, `sum(value)` and +// `sum(value²)`. +export type MatchupSums = { + voteCount: number; + valueSum: number; + squareSum: number; +}; + +export type MatchupScore = { + voteCount: number; + // Read from the lower-id Element's side, the orientation a Vote is stored in. + effectiveness: Effectiveness; + confidence: Confidence; + // The shrunk mean Vote `m`, in [−2, +2]. + meanVote: number; + // How split the crowd is, from 0 (everyone gave the same Vote) to 1. + polarisation: number; + // The posterior variance of the mean: few Votes or a split crowd weigh most. + selectionWeight: number; +}; + +// The prior: `a` phantom Votes on each of the five values before any real Vote +// arrives. They average 0, so only their weight and their `sum(value²)`, +// `a·(4 + 1 + 0 + 1 + 4)`, enter the sums. +const PRIOR_VOTES_PER_VALUE = 0.4; +const PRIOR_WEIGHT = 5 * PRIOR_VOTES_PER_VALUE; +const PRIOR_SQUARE_SUM = 10 * PRIOR_VOTES_PER_VALUE; + +// 95% of a normal distribution lies within 1.96 standard deviations. +const HALF_WIDTH_Z = 1.96; + +// The Vote furthest from the middle, which bounds a Vote's variance at +// STRONGEST_VOTE² − m² and so makes polarisation a share of 1. +const STRONGEST_VOTE = 2; + +// Where the mean Vote is rounded to the next Effectiveness step. +const WIN_MEAN = 0.5; +const CRUSH_MEAN = 1.5; + +// Controversial is a crowd split between the two sides, not one indifferent. +const CONTROVERSIAL_POLARISATION = 0.6; + +// How narrow the mean's 95% half-width has to be for each Confidence. +const SOLID_HALF_WIDTH = 0.25; +const MEDIUM_HALF_WIDTH = 0.5; + +// The Votes a Matchup needs before the reveal gives a verdict, the Voter's own +// included. +const VERDICT_VOTE_COUNT = 5; + +// The mean Vote rounded to the nearest Vote value: the step the crowd sits on. +const effectivenessStep = (meanVote: number): VoteValue => { + if (meanVote >= CRUSH_MEAN) return 2; + if (meanVote >= WIN_MEAN) return 1; + if (meanVote <= -CRUSH_MEAN) return -2; + if (meanVote <= -WIN_MEAN) return -1; + return 0; +}; + +const effectivenessOf = ( + meanVote: number, + polarisation: number, +): Effectiveness => { + const step = effectivenessStep(meanVote); + if (step === 2) return "4×"; + if (step === 1) return "2×"; + if (step === -1) return "½×"; + if (step === -2) return "¼×"; + // A crowd only counts as split where neither side is winning: a crowd that + // splits with a lean has a winner. + return polarisation >= CONTROVERSIAL_POLARISATION + ? "controversial" + : "neutral"; +}; + +const confidenceOf = (halfWidth: number): Confidence => { + if (halfWidth <= SOLID_HALF_WIDTH) return "solid"; + return halfWidth <= MEDIUM_HALF_WIDTH ? "medium" : "faint"; +}; + +// The Dirichlet-multinomial posterior of one Matchup, read out as the numbers +// the reveal, the Stats, the Stories and Matchup selection all work from. A +// Matchup with no Votes needs no special case: the prior alone scores it as +// faint Neutral. +export function scoreMatchup({ + voteCount, + valueSum, + squareSum, +}: MatchupSums): MatchupScore { + const weight = voteCount + PRIOR_WEIGHT; + const meanVote = valueSum / weight; + const voteVariance = (squareSum + PRIOR_SQUARE_SUM) / weight - meanVote ** 2; + const meanVariance = voteVariance / (weight + 1); + const polarisation = voteVariance / (STRONGEST_VOTE ** 2 - meanVote ** 2); + return { + voteCount, + effectiveness: effectivenessOf(meanVote, polarisation), + confidence: confidenceOf(HALF_WIDTH_Z * Math.sqrt(meanVariance)), + meanVote, + polarisation, + selectionWeight: meanVariance, + }; +} + +const MIRRORED_EFFECTIVENESS = { + "4×": "¼×", + "2×": "½×", + neutral: "neutral", + controversial: "controversial", + "½×": "2×", + "¼×": "4×", +} as const satisfies Record; + +// A Matchup is scored from the lower-id Element's side; the higher-id Element +// reads the same call mirrored. +export const mirrorEffectiveness = ( + effectiveness: Effectiveness, +): Effectiveness => MIRRORED_EFFECTIVENESS[effectiveness]; + +// What the reveal says to a Voter who has just cast `vote` on this Matchup, +// their own Vote counted in the score. +export function headlineFor( + { voteCount, effectiveness, meanVote }: MatchupScore, + vote: VoteValue, +): HeadlineKind { + if (voteCount < VERDICT_VOTE_COUNT) + return voteCount === 1 ? "first" : "early"; + if (effectiveness === "controversial") return "split"; + const stepsApart = Math.abs(vote - effectivenessStep(meanVote)); + if (stepsApart === 0) return "with"; + return stepsApart === 1 ? "close" : "against"; +}