diff --git a/app/services/simulations/__tests__/ncaa-football-simulator.test.ts b/app/services/simulations/__tests__/ncaa-football-simulator.test.ts index ccd7376..c099772 100644 --- a/app/services/simulations/__tests__/ncaa-football-simulator.test.ts +++ b/app/services/simulations/__tests__/ncaa-football-simulator.test.ts @@ -33,7 +33,6 @@ function makeEvRows( })); } -/** Build participant rows. */ function makeParticipants(ids: string[]) { return ids.map((id) => ({ id })); } @@ -41,192 +40,188 @@ function makeParticipants(ids: string[]) { // ─── Tests ──────────────────────────────────────────────────────────────────── describe("NCAAFootballSimulator", () => { - let mockDb: { - select: MockInstance; - }; - - // select() returns a chain: .from().where() → resolves to an array - // We need two calls: one for participants, one for EV rows. + let mockDb: { select: MockInstance }; let selectCallCount: number; beforeEach(async () => { selectCallCount = 0; const { database } = await import("~/database/context"); - mockDb = { - select: vi.fn(), - }; - + mockDb = { select: vi.fn() }; (database as unknown as MockInstance).mockReturnValue(mockDb); }); - function setupMockDb( - participantIds: string[], - opts: { includeOdds?: boolean } = {} - ) { + function setupMockDb(participantIds: string[], opts: { includeOdds?: boolean } = {}) { const participants = makeParticipants(participantIds); const evRows = makeEvRows(participantIds, opts); mockDb.select.mockImplementation(() => { const callIndex = selectCallCount++; const data = callIndex === 0 ? participants : evRows; - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(data), - }), - }; + return { from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(data) }) }; }); } - it("returns one result per participant", async () => { - setupMockDb(TEAM_IDS); - const sim = new NCAAFootballSimulator(); - const results = await sim.simulate("season-1"); + // ── Post-bracket mode (exactly 12 teams) ───────────────────────────────── - expect(results).toHaveLength(12); - }); - - it("all probabilities are between 0 and 1", 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.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([]), - }), + describe("post-bracket mode (exactly 12 teams)", () => { + it("returns one result per participant", async () => { + setupMockDb(TEAM_IDS); + const results = await new NCAAFootballSimulator().simulate("season-1"); + expect(results).toHaveLength(12); }); - const sim = new NCAAFootballSimulator(); - await expect(sim.simulate("season-1")).rejects.toThrow(/No participants found/); - }); + it("all probabilities are between 0 and 1", async () => { + setupMockDb(TEAM_IDS); + const results = await new NCAAFootballSimulator().simulate("season-1"); - it("throws when fewer than 12 participants are 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) }) }; + 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); + } }); - const sim = new NCAAFootballSimulator(); - await expect(sim.simulate("season-1")).rejects.toThrow(/12 participants/); - }); + it("top-rated team has highest champion probability", async () => { + setupMockDb(TEAM_IDS); + const results = await new NCAAFootballSimulator().simulate("season-1"); - it("uses futures odds when sourceOdds present (does not throw)", async () => { - setupMockDb(TEAM_IDS, { includeOdds: true }); - const sim = new NCAAFootballSimulator(); - 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 byId = new Map(results.map((r) => [r.participantId, r])); + expect(byId.get("team-1")?.probabilities.probFirst).toBeGreaterThan( + byId.get("team-12")?.probabilities.probFirst + ); }); - const sim = new NCAAFootballSimulator(); - const results = await sim.simulate("season-1"); + it("probFirst 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.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"]) { - const r = results.find((x) => x.participantId === id)!; - expect(r.probabilities.probFirst).toBe(0); - expect(r.probabilities.probSecond).toBe(0); - expect(r.probabilities.probThird).toBe(0); - expect(r.probabilities.probEighth).toBe(0); - } + it("probThird equals probFourth for every team (tied SF placement)", async () => { + setupMockDb(TEAM_IDS); + const results = await new NCAAFootballSimulator().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 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/); + }); }); }); diff --git a/app/services/simulations/ncaa-football-simulator.ts b/app/services/simulations/ncaa-football-simulator.ts index 1f9efd0..d41fe9f 100644 --- a/app/services/simulations/ncaa-football-simulator.ts +++ b/app/services/simulations/ncaa-football-simulator.ts @@ -7,25 +7,37 @@ * 1. Load all participants for the sports season from DB * 2. Load Elo/FPI ratings from participantExpectedValues.sourceElo * (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 - * odds-based per-game win probabilities: P = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb - * 4. Sort teams by blended strength (descending) to assign seeds 1–12 - * 5. Simulate 50,000 CFP brackets per the official seeding structure: + * 3. If sourceOdds (American format) are also stored, build a normalized selection + * weight from implied championship probability (used for field selection in step 4) + * and blend into per-game win probability (ELO_WEIGHT=0.6 / ODDS_WEIGHT=0.4). + * 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 * 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 * National Championship: SF1w vs SF2w - * 6. Track placement counts per scoring tier - * 7. Convert counts to probability distributions + * 7. Track placement counts per scoring tier across all simulations. + * 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): - * P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb - * ELO_WEIGHT = 0.6, ODDS_WEIGHT = 0.4 - * A slightly lower Elo weight than other sports (0.7) gives more influence to - * Vegas championship odds, which are highly informative in college football. - * Falls back to Elo-only when no sourceOdds are stored. + * Win probability (per game): eloWinProbability() from probability-engine (400-divisor). + * Blended win probability: ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb (when odds present). + * + * Selection weight (pre-bracket mode): + * With sourceOdds: normalized implied championship probability (vig removed, sums to 1). + * 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: * probFirst = National Champion (1 per sim) @@ -33,12 +45,14 @@ * probThird / probFourth = Semifinal losers (2 per sim — split evenly) * probFifth–probEighth = Quarterfinal losers (4 per sim — split evenly) * First Round losers → all 0 (score 0 fantasy points) + * Teams not selected → all 0 (not in field for that sim) * * Admin setup: * 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) - * 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 */ @@ -58,26 +72,39 @@ const NUM_SIMULATIONS = 50_000; const BRACKET_SIZE = 12; /** - * Weight for the Elo-based probability component. - * Remaining (ODDS_WEIGHT) goes to the Vegas futures-derived component. + * Blend weights for per-game win probability when sourceOdds are present. + * 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 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 ──────────────────────────────────────────────────────────────────── interface Team { participantId: string; elo: number; - /** Normalized futures win probability (0–1). Used for blending when odds available. */ + /** Normalized futures win probability (0–1). Used for blending per-game win prob. */ 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. - * 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 { 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 }; } +/** + * 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 ─────────────────────────────────────────────────────── interface PlacementCounts { @@ -111,7 +163,7 @@ interface PlacementCounts { /** * Simulate one full 12-team CFP bracket. * - * Seeding (teams sorted best→worst, index 0 = seed 1): + * Seeding (teams sorted best→worst Elo, index 0 = seed 1): * 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 * Semifinals: qf1w vs qf2w, qf3w vs qf4w @@ -130,23 +182,28 @@ function simulateBracket(teams: Team[], counts: Map): v const qf3 = simGame(teams[2], fr2.winner); // 3 vs 6/11 winner const qf4 = simGame(teams[1], fr3.winner); // 2 vs 7/10 winner - counts.get(qf1.loser.participantId)!.qfLoser++; - counts.get(qf2.loser.participantId)!.qfLoser++; - counts.get(qf3.loser.participantId)!.qfLoser++; - counts.get(qf4.loser.participantId)!.qfLoser++; + const bump = (id: string, key: keyof PlacementCounts) => { + const entry = counts.get(id); + if (entry) entry[key]++; + }; + + bump(qf1.loser.participantId, "qfLoser"); + bump(qf2.loser.participantId, "qfLoser"); + bump(qf3.loser.participantId, "qfLoser"); + bump(qf4.loser.participantId, "qfLoser"); // ── Semifinals ──────────────────────────────────────────────────────────── const sf1 = simGame(qf1.winner, qf2.winner); const sf2 = simGame(qf3.winner, qf4.winner); - counts.get(sf1.loser.participantId)!.sfLoser++; - counts.get(sf2.loser.participantId)!.sfLoser++; + bump(sf1.loser.participantId, "sfLoser"); + bump(sf2.loser.participantId, "sfLoser"); // ── National Championship ───────────────────────────────────────────────── const final = simGame(sf1.winner, sf2.winner); - counts.get(final.winner.participantId)!.champion++; - counts.get(final.loser.participantId)!.finalist++; + bump(final.winner.participantId, "champion"); + bump(final.loser.participantId, "finalist"); } // ─── Simulator ──────────────────────────────────────────────────────────────── @@ -161,8 +218,11 @@ export class NCAAFootballSimulator implements Simulator { .from(schema.participants) .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); - if (participants.length === 0) { - throw new Error(`No participants found for sports season ${sportsSeasonId}.`); + if (participants.length < BRACKET_SIZE) { + 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. @@ -175,7 +235,7 @@ export class NCAAFootballSimulator implements Simulator { .from(schema.participantExpectedValues) .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(); const rawOddsProbs = new Map(); @@ -189,8 +249,7 @@ export class NCAAFootballSimulator implements Simulator { } // 3. Build normalized odds probability map (vig removed). - // If no sourceOdds, all teams get oddsProb = 0 → falls back to pure Elo. - let normalizedOddsMap = new Map(); + const normalizedOddsMap = new Map(); if (rawOddsProbs.size > 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); } - // 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) { - const oddsInput = [...rawOddsProbs.keys()] - .filter((id) => !eloFromDb.has(id)) - .map((id) => ({ participantId: id, odds: evRows.find((r) => r.participantId === id)!.sourceOdds! })); + const oddsInput = evRows + .filter((r) => r.sourceOdds !== null && !eloFromDb.has(r.participantId)) + .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); if (oddsInput.length > 0) { 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) => ({ participantId: p.id, elo: eloFromDb.get(p.id) ?? 1500, oddsProb: normalizedOddsMap.get(p.id) ?? 0, + selectionWeight: 0, // computed below })); - // Normalize Elo to [0,1] range for the blended sort score. - const eloValues = allTeams.map((t) => t.elo); - const minElo = Math.min(...eloValues); - const eloRange = (Math.max(...eloValues) - minElo) || 1; - - const seededTeams = [...allTeams].sort((a, b) => { - const aScore = ELO_WEIGHT * ((a.elo - minElo) / eloRange) + ODDS_WEIGHT * a.oddsProb; - const bScore = ELO_WEIGHT * ((b.elo - minElo) / eloRange) + ODDS_WEIGHT * b.oddsProb; - return bScore - aScore; - }); - - if (seededTeams.length < BRACKET_SIZE) { - throw new Error( - `CFP simulator requires ${BRACKET_SIZE} participants, found ${seededTeams.length}. ` + - `Add all teams to the sports season before running simulation.` - ); + if (hasOdds) { + // Selection weight = normalized championship implied probability. + // This encodes both "probability of making the field" and "strength once there." + for (const team of allTeams) { + team.selectionWeight = normalizedOddsMap.get(team.participantId) ?? 0; + } + } else { + // No odds: softmax on Elo so top-rated teams are strongly favoured. + const eloValues = allTeams.map((t) => t.elo); + const maxElo = Math.max(...eloValues); + // Subtract max for numerical stability before exp(). + const expWeights = allTeams.map((t) => Math.exp((t.elo - maxElo) / SELECTION_TEMP)); + const expSum = expWeights.reduce((a, b) => a + b, 0); + for (let i = 0; i < allTeams.length; i++) { + 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. - // Teams outside the top 12 keep all zeros (0 EV). const allParticipantIds = participants.map((p) => p.id); const counts = new Map( 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. 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. - // SF losers: 2 per sim, so each team's share = sfLoser / (2 * N). - // QF losers: 4 per sim, so each team's share = qfLoser / (4 * N). + // 7. Convert counts to probability distributions. + // SF losers: 2 per sim → each team's share = sfLoser / (2 * N). + // QF losers: 4 per sim → each team's share = qfLoser / (4 * N). const sfDivisor = 2 * NUM_SIMULATIONS; const qfDivisor = 4 * NUM_SIMULATIONS; 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 qfProb = c.qfLoser / qfDivisor; return {