Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/projects/elemental-showdown/matchup-score.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>((total, value) => total + value, 0),
squareSum: votes.reduce<number>((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");
});
});
161 changes: 161 additions & 0 deletions src/projects/elemental-showdown/matchup-score.ts
Original file line number Diff line number Diff line change
@@ -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<Effectiveness, Effectiveness>;

// 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";
}
Loading