Fix lint errors in NCAA Football CFP simulator

Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-08 13:40:22 +00:00
parent 8cfbd109da
commit d991ae69f4
2 changed files with 286 additions and 221 deletions

View file

@ -33,7 +33,6 @@ function makeEvRows(
})); }));
} }
/** Build participant rows. */
function makeParticipants(ids: string[]) { function makeParticipants(ids: string[]) {
return ids.map((id) => ({ id })); return ids.map((id) => ({ id }));
} }
@ -41,192 +40,188 @@ function makeParticipants(ids: string[]) {
// ─── Tests ──────────────────────────────────────────────────────────────────── // ─── Tests ────────────────────────────────────────────────────────────────────
describe("NCAAFootballSimulator", () => { describe("NCAAFootballSimulator", () => {
let mockDb: { let mockDb: { select: MockInstance };
select: MockInstance;
};
// select() returns a chain: .from().where() → resolves to an array
// We need two calls: one for participants, one for EV rows.
let selectCallCount: number; let selectCallCount: number;
beforeEach(async () => { beforeEach(async () => {
selectCallCount = 0; selectCallCount = 0;
const { database } = await import("~/database/context"); const { database } = await import("~/database/context");
mockDb = { mockDb = { select: vi.fn() };
select: vi.fn(),
};
(database as unknown as MockInstance).mockReturnValue(mockDb); (database as unknown as MockInstance).mockReturnValue(mockDb);
}); });
function setupMockDb( function setupMockDb(participantIds: string[], opts: { includeOdds?: boolean } = {}) {
participantIds: string[],
opts: { includeOdds?: boolean } = {}
) {
const participants = makeParticipants(participantIds); const participants = makeParticipants(participantIds);
const evRows = makeEvRows(participantIds, opts); const evRows = makeEvRows(participantIds, opts);
mockDb.select.mockImplementation(() => { mockDb.select.mockImplementation(() => {
const callIndex = selectCallCount++; const callIndex = selectCallCount++;
const data = callIndex === 0 ? participants : evRows; const data = callIndex === 0 ? participants : evRows;
return { return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(data),
}),
};
}); });
} }
it("returns one result per participant", async () => { // ── Post-bracket mode (exactly 12 teams) ─────────────────────────────────
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
expect(results).toHaveLength(12); describe("post-bracket mode (exactly 12 teams)", () => {
}); it("returns one result per participant", async () => {
setupMockDb(TEAM_IDS);
it("all probabilities are between 0 and 1", async () => { const results = await new NCAAFootballSimulator().simulate("season-1");
setupMockDb(TEAM_IDS); expect(results).toHaveLength(12);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
const p = r.probabilities;
expect(p.probFirst).toBeGreaterThanOrEqual(0);
expect(p.probFirst).toBeLessThanOrEqual(1);
expect(p.probSecond).toBeGreaterThanOrEqual(0);
expect(p.probSecond).toBeLessThanOrEqual(1);
}
});
it("top-rated team has highest champion probability", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
const team1Prob = byId.get("team-1")!.probabilities.probFirst;
const team12Prob = byId.get("team-12")!.probabilities.probFirst;
expect(team1Prob).toBeGreaterThan(team12Prob);
});
it("probFirst sums to approximately 1.0 across all teams", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
const total = results.reduce((sum, r) => sum + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probSecond sums to approximately 1.0 across all teams", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
const total = results.reduce((sum, r) => sum + r.probabilities.probSecond, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("probThird equals probFourth for every team (tied SF placement)", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.probabilities.probThird).toBeCloseTo(r.probabilities.probFourth, 10);
}
});
it("probFifth through probEighth are equal for every team (tied QF placement)", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
const p = r.probabilities;
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
}
});
it("weakest team has very low champion probability", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
// team-12 (Elo 1150) vs team-1 (Elo 1700): team-12 should rarely win
const byId = new Map(results.map((r) => [r.participantId, r]));
expect(byId.get("team-12")!.probabilities.probFirst).toBeLessThan(0.02);
});
it("source is cfp_monte_carlo", async () => {
setupMockDb(TEAM_IDS);
const sim = new NCAAFootballSimulator();
const results = await sim.simulate("season-1");
for (const r of results) {
expect(r.source).toBe("cfp_monte_carlo");
}
});
it("throws when no participants found", async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}); });
const sim = new NCAAFootballSimulator(); it("all probabilities are between 0 and 1", async () => {
await expect(sim.simulate("season-1")).rejects.toThrow(/No participants found/); setupMockDb(TEAM_IDS);
}); const results = await new NCAAFootballSimulator().simulate("season-1");
it("throws when fewer than 12 participants are provided", async () => { for (const r of results) {
const elevenTeams = TEAM_IDS.slice(0, 11); const p = r.probabilities;
let call = 0; expect(p.probFirst).toBeGreaterThanOrEqual(0);
mockDb.select.mockImplementation(() => { expect(p.probFirst).toBeLessThanOrEqual(1);
const data = call++ === 0 ? makeParticipants(elevenTeams) : makeEvRows(elevenTeams); expect(p.probSecond).toBeGreaterThanOrEqual(0);
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) }; expect(p.probSecond).toBeLessThanOrEqual(1);
}
}); });
const sim = new NCAAFootballSimulator(); it("top-rated team has highest champion probability", async () => {
await expect(sim.simulate("season-1")).rejects.toThrow(/12 participants/); setupMockDb(TEAM_IDS);
}); const results = await new NCAAFootballSimulator().simulate("season-1");
it("uses futures odds when sourceOdds present (does not throw)", async () => { const byId = new Map(results.map((r) => [r.participantId, r]));
setupMockDb(TEAM_IDS, { includeOdds: true }); expect(byId.get("team-1")?.probabilities.probFirst).toBeGreaterThan(
const sim = new NCAAFootballSimulator(); byId.get("team-12")?.probabilities.probFirst
const results = await sim.simulate("season-1"); );
// Should still return 12 results with valid probabilities
expect(results).toHaveLength(12);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("teams outside top 12 have all-zero probabilities (15-team season)", async () => {
// 15 participants — only top 12 enter the bracket, bottom 3 stay at 0 EV
const fifteenTeams = Array.from({ length: 15 }, (_, i) => `team-${i + 1}`);
let call = 0;
mockDb.select.mockImplementation(() => {
const data = call++ === 0 ? makeParticipants(fifteenTeams) : makeEvRows(fifteenTeams);
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
}); });
const sim = new NCAAFootballSimulator(); it("probFirst sums to approximately 1.0", async () => {
const results = await sim.simulate("season-1"); setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
const total = results.reduce((sum, r) => sum + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
expect(results).toHaveLength(15); it("probSecond sums to approximately 1.0", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
const total = results.reduce((sum, r) => sum + r.probabilities.probSecond, 0);
expect(total).toBeCloseTo(1.0, 1);
});
for (const id of ["team-13", "team-14", "team-15"]) { it("probThird equals probFourth for every team (tied SF placement)", async () => {
const r = results.find((x) => x.participantId === id)!; setupMockDb(TEAM_IDS);
expect(r.probabilities.probFirst).toBe(0); const results = await new NCAAFootballSimulator().simulate("season-1");
expect(r.probabilities.probSecond).toBe(0); for (const r of results) {
expect(r.probabilities.probThird).toBe(0); expect(r.probabilities.probThird).toBeCloseTo(r.probabilities.probFourth, 10);
expect(r.probabilities.probEighth).toBe(0); }
} });
it("probFifth through probEighth are equal for every team (tied QF placement)", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
for (const r of results) {
const p = r.probabilities;
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
}
});
it("weakest team has very low champion probability", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
// team-12 (Elo 1150) vs team-1 (Elo 1700): 550-point gap → should rarely win
expect(byId.get("team-12")?.probabilities.probFirst).toBeLessThan(0.02);
});
it("source is cfp_monte_carlo", async () => {
setupMockDb(TEAM_IDS);
const results = await new NCAAFootballSimulator().simulate("season-1");
for (const r of results) {
expect(r.source).toBe("cfp_monte_carlo");
}
});
it("uses futures odds when sourceOdds present", async () => {
setupMockDb(TEAM_IDS, { includeOdds: true });
const results = await new NCAAFootballSimulator().simulate("season-1");
expect(results).toHaveLength(12);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
});
// ── Pre-bracket mode (>12 teams — probabilistic selection) ───────────────
describe("pre-bracket mode (>12 teams)", () => {
it("returns one result per participant when pool is larger than 12", async () => {
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
setupMockDb(twentyTeams);
const results = await new NCAAFootballSimulator().simulate("season-1");
expect(results).toHaveLength(20);
});
it("probFirst still sums to ~1.0 across all teams (one champion per sim)", async () => {
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
setupMockDb(twentyTeams);
const results = await new NCAAFootballSimulator().simulate("season-1");
const total = results.reduce((sum, r) => sum + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
it("top team has much higher probFirst than bottom team in larger pool", async () => {
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
setupMockDb(twentyTeams);
const results = await new NCAAFootballSimulator().simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
// team-1 (Elo 1700) should win far more often than team-20 (Elo 750)
expect(byId.get("team-1")?.probabilities.probFirst).toBeGreaterThan(
(byId.get("team-20")?.probabilities.probFirst ?? 0) * 10
);
});
it("bottom-pool teams (large Elo gap) rarely appear in champion slot", async () => {
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
setupMockDb(twentyTeams);
const results = await new NCAAFootballSimulator().simulate("season-1");
const byId = new Map(results.map((r) => [r.participantId, r]));
// team-19 and team-20 have Elos of 800 and 750 — far below the top 12 (~1150+)
// With softmax T=100, their selection weight is negligible
expect(byId.get("team-19")?.probabilities.probFirst).toBeLessThan(0.01);
expect(byId.get("team-20")?.probabilities.probFirst).toBeLessThan(0.01);
});
it("uses futures odds as selection weights when provided", async () => {
const twentyTeams = Array.from({ length: 20 }, (_, i) => `team-${i + 1}`);
setupMockDb(twentyTeams, { includeOdds: true });
const results = await new NCAAFootballSimulator().simulate("season-1");
expect(results).toHaveLength(20);
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 1);
});
});
// ── Error cases ───────────────────────────────────────────────────────────
describe("error cases", () => {
it("throws when fewer than 12 participants provided", async () => {
const elevenTeams = TEAM_IDS.slice(0, 11);
let call = 0;
mockDb.select.mockImplementation(() => {
const data = call++ === 0 ? makeParticipants(elevenTeams) : makeEvRows(elevenTeams);
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
await expect(new NCAAFootballSimulator().simulate("season-1")).rejects.toThrow(/at least 12/);
});
it("throws when participant list is empty", async () => {
let call = 0;
mockDb.select.mockImplementation(() => {
const data = call++ === 0 ? [] : [];
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) };
});
await expect(new NCAAFootballSimulator().simulate("season-1")).rejects.toThrow(/at least 12/);
});
}); });
}); });

View file

@ -7,25 +7,37 @@
* 1. Load all participants for the sports season from DB * 1. Load all participants for the sports season from DB
* 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo * 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo
* (entered via Admin Elo Ratings page; use FPI, S&P+, or any Elo-scale rating) * (entered via Admin Elo Ratings page; use FPI, S&P+, or any Elo-scale rating)
* 3. If sourceOdds (American format) are also stored, blend the Elo-based and * 3. If sourceOdds (American format) are also stored, build a normalized selection
* odds-based per-game win probabilities: P = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb * weight from implied championship probability (used for field selection in step 4)
* 4. Sort teams by blended strength (descending) to assign seeds 112 * and blend into per-game win probability (ELO_WEIGHT=0.6 / ODDS_WEIGHT=0.4).
* 5. Simulate 50,000 CFP brackets per the official seeding structure: * 4. Per simulation, select 12 teams for the CFP field:
* - If the pool has exactly 12 teams: use all of them (post-bracket mode).
* - If the pool has >12 teams: weighted sample without replacement using each
* team's selection weight odds-derived if available, Elo-based otherwise.
* Teams with stronger championship odds are sampled more often, naturally
* encoding both selection probability and bracket strength into one signal.
* 5. Seed the 12 selected teams by Elo (best Elo = seed 1).
* 6. Simulate the CFP bracket:
* First Round (not scoring): 5v12, 6v11, 7v10, 8v9 * First Round (not scoring): 5v12, 6v11, 7v10, 8v9
* Quarterfinals (scoring): 1 vs 8/9w, 4 vs 5/12w, 3 vs 6/11w, 2 vs 7/10w * Quarterfinals (scoring): 1 vs 8/9w, 4 vs 5/12w, 3 vs 6/11w, 2 vs 7/10w
* Semifinals (scoring): QF1w vs QF2w, QF3w vs QF4w * Semifinals (scoring): QF1w vs QF2w, QF3w vs QF4w
* National Championship: SF1w vs SF2w * National Championship: SF1w vs SF2w
* 6. Track placement counts per scoring tier * 7. Track placement counts per scoring tier across all simulations.
* 7. Convert counts to probability distributions * 8. Convert counts to probability distributions.
* *
* Win probability: eloWinProbability() from probability-engine (standard 400-divisor Elo formula). * Pre-bracket vs post-bracket mode:
* Pre-bracket (>12 participants): probabilities reflect both selection uncertainty
* and bracket performance. A bubble team might appear in only 40% of simulated
* fields, so its champion probability accounts for that.
* Post-bracket (exactly 12 participants): deterministic field, bracket-only sim.
* *
* Futures blending (when sourceOdds are present): * Win probability (per game): eloWinProbability() from probability-engine (400-divisor).
* P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb * Blended win probability: ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb (when odds present).
* ELO_WEIGHT = 0.6, ODDS_WEIGHT = 0.4 *
* A slightly lower Elo weight than other sports (0.7) gives more influence to * Selection weight (pre-bracket mode):
* Vegas championship odds, which are highly informative in college football. * With sourceOdds: normalized implied championship probability (vig removed, sums to 1).
* Falls back to Elo-only when no sourceOdds are stored. * Without sourceOdds: softmax on Elo with temperature SELECTION_TEMP (sharply favors
* higher-rated teams a 200-point Elo gap yields ~7× selection weight difference).
* *
* Placement tiers SimulationProbabilities mapping: * Placement tiers SimulationProbabilities mapping:
* probFirst = National Champion (1 per sim) * probFirst = National Champion (1 per sim)
@ -33,12 +45,14 @@
* probThird / probFourth = Semifinal losers (2 per sim split evenly) * probThird / probFourth = Semifinal losers (2 per sim split evenly)
* probFifthprobEighth = Quarterfinal losers (4 per sim split evenly) * probFifthprobEighth = Quarterfinal losers (4 per sim split evenly)
* First Round losers all 0 (score 0 fantasy points) * First Round losers all 0 (score 0 fantasy points)
* Teams not selected all 0 (not in field for that sim)
* *
* Admin setup: * Admin setup:
* 1. Create a Sport with simulatorType = "ncaa_football_bracket" * 1. Create a Sport with simulatorType = "ncaa_football_bracket"
* 2. Create a Sports Season and add 12 team participants * 2. Create a Sports Season and add all contender participants (12 or more)
* 3. Enter FPI ratings via Admin Elo Ratings (stored as sourceElo) * 3. Enter FPI ratings via Admin Elo Ratings (stored as sourceElo)
* 4. Optionally enter championship futures odds via Admin Futures Odds (stored as sourceOdds) * 4. Optionally enter championship futures odds via Admin Futures Odds (sourceOdds)
* strongly recommended for pre-bracket mode; drives both selection and bracket strength
* 5. Run simulation via Admin Simulate * 5. Run simulation via Admin Simulate
*/ */
@ -58,26 +72,39 @@ const NUM_SIMULATIONS = 50_000;
const BRACKET_SIZE = 12; const BRACKET_SIZE = 12;
/** /**
* Weight for the Elo-based probability component. * Blend weights for per-game win probability when sourceOdds are present.
* Remaining (ODDS_WEIGHT) goes to the Vegas futures-derived component. * Lower Elo weight than other sports (0.7) gives more influence to Vegas
* championship odds, which are highly informative in college football.
*/ */
const ELO_WEIGHT = 0.6; const ELO_WEIGHT = 0.6;
const ODDS_WEIGHT = 1 - ELO_WEIGHT; const ODDS_WEIGHT = 1 - ELO_WEIGHT;
/**
* Softmax temperature for Elo-based selection weights (pre-bracket mode, no odds).
* At T=100, a 200-point Elo gap produces ~7× weight difference enough to strongly
* favour the top teams while still giving bubble teams meaningful selection probability.
*/
const SELECTION_TEMP = 100;
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
interface Team { interface Team {
participantId: string; participantId: string;
elo: number; elo: number;
/** Normalized futures win probability (01). Used for blending when odds available. */ /** Normalized futures win probability (01). Used for blending per-game win prob. */
oddsProb: number; oddsProb: number;
/**
* Weight used for probabilistic CFP field selection (pre-bracket mode only).
* Derived from oddsProb when available; otherwise softmax on Elo.
*/
selectionWeight: number;
} }
// ─── Win probability helpers ────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
/** /**
* Blended win probability for team1 vs team2. * Blended win probability for team1 vs team2.
* When oddsProbs are both 0 (no futures data), falls back to pure Elo. * Falls back to pure Elo when no futures data is present.
*/ */
function blendedWinProb(team1: Team, team2: Team): number { function blendedWinProb(team1: Team, team2: Team): number {
const eloProbValue = eloWinProbability(team1.elo, team2.elo); const eloProbValue = eloWinProbability(team1.elo, team2.elo);
@ -99,6 +126,31 @@ function simGame(team1: Team, team2: Team): { winner: Team; loser: Team } {
: { winner: team2, loser: team1 }; : { winner: team2, loser: team1 };
} }
/**
* Weighted sample without replacement selects `n` teams from `pool` where each
* team's probability of being drawn is proportional to its selectionWeight.
* Returns the selected teams sorted by Elo descending (seed 1 = best Elo).
*/
function sampleBracketField(pool: Team[], n: number): Team[] {
const remaining = [...pool];
const selected: Team[] = [];
for (let i = 0; i < n; i++) {
const totalWeight = remaining.reduce((sum, t) => sum + t.selectionWeight, 0);
let r = Math.random() * totalWeight;
let j = 0;
for (; j < remaining.length - 1; j++) {
r -= remaining[j].selectionWeight;
if (r <= 0) break;
}
selected.push(remaining[j]);
remaining.splice(j, 1);
}
// Seed by Elo so that the best team in the sampled field is always seed 1.
return selected.toSorted((a, b) => b.elo - a.elo);
}
// ─── Bracket simulation ─────────────────────────────────────────────────────── // ─── Bracket simulation ───────────────────────────────────────────────────────
interface PlacementCounts { interface PlacementCounts {
@ -111,7 +163,7 @@ interface PlacementCounts {
/** /**
* Simulate one full 12-team CFP bracket. * Simulate one full 12-team CFP bracket.
* *
* Seeding (teams sorted bestworst, index 0 = seed 1): * Seeding (teams sorted bestworst Elo, index 0 = seed 1):
* First Round: [4]v[11], [5]v[10], [6]v[9], [7]v[8] * First Round: [4]v[11], [5]v[10], [6]v[9], [7]v[8]
* Quarterfinals: [0] vs fr4w, [3] vs fr1w, [2] vs fr2w, [1] vs fr3w * Quarterfinals: [0] vs fr4w, [3] vs fr1w, [2] vs fr2w, [1] vs fr3w
* Semifinals: qf1w vs qf2w, qf3w vs qf4w * Semifinals: qf1w vs qf2w, qf3w vs qf4w
@ -130,23 +182,28 @@ function simulateBracket(teams: Team[], counts: Map<string, PlacementCounts>): v
const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner
const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner
counts.get(qf1.loser.participantId)!.qfLoser++; const bump = (id: string, key: keyof PlacementCounts) => {
counts.get(qf2.loser.participantId)!.qfLoser++; const entry = counts.get(id);
counts.get(qf3.loser.participantId)!.qfLoser++; if (entry) entry[key]++;
counts.get(qf4.loser.participantId)!.qfLoser++; };
bump(qf1.loser.participantId, "qfLoser");
bump(qf2.loser.participantId, "qfLoser");
bump(qf3.loser.participantId, "qfLoser");
bump(qf4.loser.participantId, "qfLoser");
// ── Semifinals ──────────────────────────────────────────────────────────── // ── Semifinals ────────────────────────────────────────────────────────────
const sf1 = simGame(qf1.winner, qf2.winner); const sf1 = simGame(qf1.winner, qf2.winner);
const sf2 = simGame(qf3.winner, qf4.winner); const sf2 = simGame(qf3.winner, qf4.winner);
counts.get(sf1.loser.participantId)!.sfLoser++; bump(sf1.loser.participantId, "sfLoser");
counts.get(sf2.loser.participantId)!.sfLoser++; bump(sf2.loser.participantId, "sfLoser");
// ── National Championship ───────────────────────────────────────────────── // ── National Championship ─────────────────────────────────────────────────
const final = simGame(sf1.winner, sf2.winner); const final = simGame(sf1.winner, sf2.winner);
counts.get(final.winner.participantId)!.champion++; bump(final.winner.participantId, "champion");
counts.get(final.loser.participantId)!.finalist++; bump(final.loser.participantId, "finalist");
} }
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
@ -161,8 +218,11 @@ export class NCAAFootballSimulator implements Simulator {
.from(schema.participants) .from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); .where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (participants.length === 0) { if (participants.length < BRACKET_SIZE) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`); throw new Error(
`CFP simulator requires at least ${BRACKET_SIZE} participants, ` +
`found ${participants.length}. Add all contender teams to the sports season.`
);
} }
// 2. Load Elo/FPI ratings and optional futures odds in a single query. // 2. Load Elo/FPI ratings and optional futures odds in a single query.
@ -175,7 +235,7 @@ export class NCAAFootballSimulator implements Simulator {
.from(schema.participantExpectedValues) .from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)); .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
// Build Elo and odds maps in a single pass over evRows. // Build Elo and raw odds maps in a single pass.
const eloFromDb = new Map<string, number>(); const eloFromDb = new Map<string, number>();
const rawOddsProbs = new Map<string, number>(); const rawOddsProbs = new Map<string, number>();
@ -189,8 +249,7 @@ export class NCAAFootballSimulator implements Simulator {
} }
// 3. Build normalized odds probability map (vig removed). // 3. Build normalized odds probability map (vig removed).
// If no sourceOdds, all teams get oddsProb = 0 → falls back to pure Elo. const normalizedOddsMap = new Map<string, number>();
let normalizedOddsMap = new Map<string, number>();
if (rawOddsProbs.size > 0) { if (rawOddsProbs.size > 0) {
const rawSum = [...rawOddsProbs.values()].reduce((a, b) => a + b, 0); const rawSum = [...rawOddsProbs.values()].reduce((a, b) => a + b, 0);
@ -198,11 +257,11 @@ export class NCAAFootballSimulator implements Simulator {
normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0); normalizedOddsMap.set(id, rawSum > 0 ? prob / rawSum : 0);
} }
// Backfill Elo from futures odds for any team that has sourceOdds but no sourceElo. // Backfill Elo from futures for any team missing sourceElo.
if (eloFromDb.size < participants.length) { if (eloFromDb.size < participants.length) {
const oddsInput = [...rawOddsProbs.keys()] const oddsInput = evRows
.filter((id) => !eloFromDb.has(id)) .filter((r) => r.sourceOdds !== null && !eloFromDb.has(r.participantId))
.map((id) => ({ participantId: id, odds: evRows.find((r) => r.participantId === id)!.sourceOdds! })); .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
if (oddsInput.length > 0) { if (oddsInput.length > 0) {
const oddsEloMap = convertFuturesToElo(oddsInput, "american"); const oddsEloMap = convertFuturesToElo(oddsInput, "american");
@ -213,34 +272,42 @@ export class NCAAFootballSimulator implements Simulator {
} }
} }
// 4. Build and seed team list (top 12 by blended strength, best→worst). // 4. Build team list with Elo, oddsProb, and selectionWeight.
const hasOdds = normalizedOddsMap.size > 0;
const allTeams: Team[] = participants.map((p) => ({ const allTeams: Team[] = participants.map((p) => ({
participantId: p.id, participantId: p.id,
elo: eloFromDb.get(p.id) ?? 1500, elo: eloFromDb.get(p.id) ?? 1500,
oddsProb: normalizedOddsMap.get(p.id) ?? 0, oddsProb: normalizedOddsMap.get(p.id) ?? 0,
selectionWeight: 0, // computed below
})); }));
// Normalize Elo to [0,1] range for the blended sort score. if (hasOdds) {
const eloValues = allTeams.map((t) => t.elo); // Selection weight = normalized championship implied probability.
const minElo = Math.min(...eloValues); // This encodes both "probability of making the field" and "strength once there."
const eloRange = (Math.max(...eloValues) - minElo) || 1; for (const team of allTeams) {
team.selectionWeight = normalizedOddsMap.get(team.participantId) ?? 0;
const seededTeams = [...allTeams].sort((a, b) => { }
const aScore = ELO_WEIGHT * ((a.elo - minElo) / eloRange) + ODDS_WEIGHT * a.oddsProb; } else {
const bScore = ELO_WEIGHT * ((b.elo - minElo) / eloRange) + ODDS_WEIGHT * b.oddsProb; // No odds: softmax on Elo so top-rated teams are strongly favoured.
return bScore - aScore; const eloValues = allTeams.map((t) => t.elo);
}); const maxElo = Math.max(...eloValues);
// Subtract max for numerical stability before exp().
if (seededTeams.length < BRACKET_SIZE) { const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP));
throw new Error( const expSum = expWeights.reduce((a, b) => a + b, 0);
`CFP simulator requires ${BRACKET_SIZE} participants, found ${seededTeams.length}. ` + for (let i = 0; i < allTeams.length; i++) {
`Add all teams to the sports season before running simulation.` allTeams[i].selectionWeight = expWeights[i] / expSum;
); }
} }
const bracketTeams = seededTeams.slice(0, BRACKET_SIZE);
const preBracketMode = participants.length > BRACKET_SIZE;
// In post-bracket mode (exactly 12), sort once and reuse the same field every sim.
const deterministicField = preBracketMode
? null
: [...allTeams].toSorted((a, b) => b.elo - a.elo);
// 5. Initialise placement count accumulators for all participants. // 5. Initialise placement count accumulators for all participants.
// Teams outside the top 12 keep all zeros (0 EV).
const allParticipantIds = participants.map((p) => p.id); const allParticipantIds = participants.map((p) => p.id);
const counts = new Map<string, PlacementCounts>( const counts = new Map<string, PlacementCounts>(
allParticipantIds.map((id) => [id, { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 }]) allParticipantIds.map((id) => [id, { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 }])
@ -248,17 +315,20 @@ export class NCAAFootballSimulator implements Simulator {
// 6. Run Monte Carlo simulations. // 6. Run Monte Carlo simulations.
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
simulateBracket(bracketTeams, counts); const field = preBracketMode
? sampleBracketField(allTeams, BRACKET_SIZE)
: (deterministicField ?? []);
simulateBracket(field, counts);
} }
// 7. Convert counts to probability distributions and return. // 7. Convert counts to probability distributions.
// SF losers: 2 per sim, so each team's share = sfLoser / (2 * N). // SF losers: 2 per sim each team's share = sfLoser / (2 * N).
// QF losers: 4 per sim, so each team's share = qfLoser / (4 * N). // QF losers: 4 per sim each team's share = qfLoser / (4 * N).
const sfDivisor = 2 * NUM_SIMULATIONS; const sfDivisor = 2 * NUM_SIMULATIONS;
const qfDivisor = 4 * NUM_SIMULATIONS; const qfDivisor = 4 * NUM_SIMULATIONS;
return allParticipantIds.map((id) => { return allParticipantIds.map((id) => {
const c = counts.get(id)!; const c = counts.get(id) ?? { champion: 0, finalist: 0, sfLoser: 0, qfLoser: 0 };
const sfProb = c.sfLoser / sfDivisor; const sfProb = c.sfLoser / sfDivisor;
const qfProb = c.qfLoser / qfDivisor; const qfProb = c.qfLoser / qfDivisor;
return { return {