diff --git a/app/services/simulations/__tests__/cs-major-simulator.test.ts b/app/services/simulations/__tests__/cs-major-simulator.test.ts index b92287d..36c56ec 100644 --- a/app/services/simulations/__tests__/cs-major-simulator.test.ts +++ b/app/services/simulations/__tests__/cs-major-simulator.test.ts @@ -7,6 +7,7 @@ import { simulateChampionsStage, calcStage3ExitQP, simulateOneMajor, + reconcileAdvancers, } from "../cs-major-simulator"; import type { AdvancedTeam, BracketMatchInput, SwissMatchInput, StageResultsMap } from "../cs-major-simulator"; @@ -574,6 +575,58 @@ describe("simulateSwiss with initialRecords (partial stage locking)", () => { }); }); +// ─── reconcileAdvancers (ragged-snapshot repair, protecting locked teams) ───── + +describe("reconcileAdvancers", () => { + const adv = (id: string, losses: number, rank: number): AdvancedTeam => ({ id, elo: 1500, rank, losses }); + const elim = (id: string, wins: number, rank: number) => ({ id, elo: 1500, rank, wins }); + + it("is a no-op when the advancer count already matches the target", () => { + const input = { + advanced: [adv("a", 0, 1), adv("b", 1, 2)], + eliminated: [elim("c", 2, 3)], + }; + expect(reconcileAdvancers(input, 2)).toBe(input); // same reference, untouched + }); + + it("demotes simulated advancers before locked ones when over-filled", () => { + // 3 advancers, target 2. The locked team is the weakest (most losses) so a + // naive demotion would drop it; protection must keep it and demote a + // simulated team instead. + const input = { + advanced: [adv("sim-strong", 0, 1), adv("sim-weak", 2, 3), adv("locked", 2, 2)], + eliminated: [] as Array<{ id: string; elo: number; rank: number; wins: number }>, + }; + const result = reconcileAdvancers(input, 2, new Set(["locked"])); + expect(result.advanced.map((t) => t.id).sort()).toEqual(["locked", "sim-strong"]); + expect(result.eliminated.map((t) => t.id)).toEqual(["sim-weak"]); + }); + + it("promotes non-locked eliminated teams before locked ones when under-filled", () => { + // 1 advancer, target 2 → must promote one eliminated team. The locked + // (real) elimination must be preserved; the non-locked team is promoted + // even though it is the weaker (fewer wins) of the two. + const input = { + advanced: [adv("a", 0, 1)], + eliminated: [elim("locked-strong", 2, 2), elim("sim-weak", 0, 3)], + }; + const result = reconcileAdvancers(input, 2, new Set(["locked-strong"])); + expect(result.advanced.map((t) => t.id).sort()).toEqual(["a", "sim-weak"]); + expect(result.eliminated.map((t) => t.id)).toEqual(["locked-strong"]); + }); + + it("falls back to locked teams only when non-locked candidates run out", () => { + // 3 advancers, target 1, all locked → forced to demote locked teams, + // keeping the strongest (fewest losses). + const input = { + advanced: [adv("x", 2, 3), adv("y", 0, 1), adv("z", 1, 2)], + eliminated: [] as Array<{ id: string; elo: number; rank: number; wins: number }>, + }; + const result = reconcileAdvancers(input, 1, new Set(["x", "y", "z"])); + expect(result.advanced.map((t) => t.id)).toEqual(["y"]); // strongest kept + }); +}); + // ─── simulateChampionsStage honoring a real bracket ─────────────────────────── function makeChampTeams(): AdvancedTeam[] { @@ -650,6 +703,30 @@ describe("simulateChampionsStage honoring a real bracket", () => { } expect(aWins / 500).toBeGreaterThan(0.25); // strongest team wins well above 1/8 }); + + it("ignores recorded results when the bracket's teams are not this field", () => { + // Bracket describes a different set of teams (x1…x8) than the actual + // qualifiers (a…h), so it is not a usable real bracket: recorded winners + // must not bleed into the Elo-seeded simulation of the real field. + const teams = makeChampTeams(); + const foreignBracket: BracketMatchInput[] = makeFullBracket().map((m) => ({ + ...m, + participant1Id: m.participant1Id ? `x-${m.participant1Id}` : null, + participant2Id: m.participant2Id ? `x-${m.participant2Id}` : null, + winnerId: m.winnerId ? `x-${m.winnerId}` : null, + })); + + let aWins = 0; + for (let i = 0; i < 500; i++) { + const result = simulateChampionsStage(teams, foreignBracket); + // All 8 real teams still get a placement (the bracket was not applied). + expect(result.placements.size).toBe(8); + if (result.placements.get("a") === 1) aWins++; + } + // Strongest real team wins well above 1/8 → Elo seeding drove it, not the + // foreign bracket (which would have forced its own — absent — winner). + expect(aWins / 500).toBeGreaterThan(0.25); + }); }); // ─── simulateOneMajor full conditioning (stages + bracket + recorded QP) ────── diff --git a/app/services/simulations/cs-major-simulator.ts b/app/services/simulations/cs-major-simulator.ts index c01852e..6442acf 100644 --- a/app/services/simulations/cs-major-simulator.ts +++ b/app/services/simulations/cs-major-simulator.ts @@ -172,6 +172,14 @@ export interface SimulateOneMajorOptions { bracketMatches?: BracketMatchInput[]; /** Real Swiss match results, used to reconstruct mid-stage records. */ swissMatches?: SwissMatchInput[]; + /** + * Per-stage reconstructed (wins, losses) records, keyed by stage number. + * These depend only on `swissMatches` and are invariant across Monte Carlo + * iterations, so the caller may precompute them once and pass them in to + * avoid re-deriving them on every iteration. When omitted, they are + * reconstructed from `swissMatches` on demand. + */ + swissRecords?: Map>; /** participantId → QP already recorded in event_results (provisional/locked). */ recordedResults?: Map; } @@ -439,23 +447,6 @@ export function simulateChampionsStage( return Math.random() < p ? t1 : t2; }; - const recordedMatch = (round: string, matchNumber: number): BracketMatchInput | undefined => - bracketMatches?.find((m) => m.round === round && m.matchNumber === matchNumber); - - // Honor a completed match's recorded winner; otherwise simulate. - const resolve = ( - t1: AdvancedTeam, - t2: AdvancedTeam, - winsNeeded: number, - rec: BracketMatchInput | undefined - ): AdvancedTeam => { - if (rec?.isComplete && rec.winnerId) { - if (rec.winnerId === t1.id) return t1; - if (rec.winnerId === t2.id) return t2; - } - return simMatch(t1, t2, winsNeeded); - }; - // Determine QF pairings from the real bracket when it is fully seeded with // teams from this field; otherwise seed by stage performance. const qf = bracketMatches @@ -471,6 +462,28 @@ export function simulateChampionsStage( byId.has(m.participant2Id) ); + // Only honor recorded results when the real bracket pairings are in use; + // applying them to fictitious Elo-seeded pairings would mix real outcomes + // into matchups that never existed. + const recordedMatch = (round: string, matchNumber: number): BracketMatchInput | undefined => + realBracket + ? bracketMatches?.find((m) => m.round === round && m.matchNumber === matchNumber) + : undefined; + + // Honor a completed match's recorded winner; otherwise simulate. + const resolve = ( + t1: AdvancedTeam, + t2: AdvancedTeam, + winsNeeded: number, + rec: BracketMatchInput | undefined + ): AdvancedTeam => { + if (rec?.isComplete && rec.winnerId) { + if (rec.winnerId === t1.id) return t1; + if (rec.winnerId === t2.id) return t2; + } + return simMatch(t1, t2, winsNeeded); + }; + let qfPairs: [AdvancedTeam, AdvancedTeam][]; if (realBracket && qf) { qfPairs = qf.map((m) => [byId.get(m.participant1Id!)!, byId.get(m.participant2Id!)!]); @@ -669,6 +682,18 @@ export class CSMajorSimulator implements Simulator { ]) ); + // Per-stage W–L records depend only on the (loop-invariant) Swiss matches, + // so reconstruct them once per event here rather than on every iteration. + const eventSwissRecords = new Map< + string, + Map> + >( + [...eventSwiss].map(([id, matches]) => [ + id, + new Map([1, 2, 3].map((stageNum) => [stageNum, reconstructStageRecords(matches, stageNum)])), + ]) + ); + const eventRecordedQP = new Map>(); if (incompleteEventIds.length > 0) { const provisionalResults = await db @@ -727,6 +752,7 @@ export class CSMajorSimulator implements Simulator { const eventQP = simulateOneMajor(eventPool, stageResultsForEvent, qpConfig, { bracketMatches: eventBracket.get(event.id), swissMatches: eventSwiss.get(event.id), + swissRecords: eventSwissRecords.get(event.id), recordedResults: eventRecordedQP.get(event.id), }); for (const [pid, qp] of eventQP) { @@ -805,18 +831,28 @@ function reconstructStageRecords( * - When the stage is complete (8 eliminations known) every other team is a * locked advancer. * - Remaining teams carry their partial real record (0–0 if none). - * Returns the seed map plus the set of teams locked in as eliminated (whose QP - * is settled, so recorded provisional QP may be applied verbatim). + * Returns the seed map plus the sets of teams locked in as eliminated or + * advanced from real data. Locked-eliminated teams have settled QP (recorded + * provisional QP may be applied verbatim); both sets are protected from being + * flipped by reconcileAdvancers. + * + * `recon` may be supplied precomputed (it is invariant across Monte Carlo + * iterations); otherwise it is reconstructed from `swissMatches`. */ function buildStageInitialRecords( stageTeams: TeamWithElo[], stageNum: number, swissMatches: SwissMatchInput[] | undefined, - stageResults: StageResultsMap | undefined -): { initialRecords: Map; lockedEliminated: Set } { - const recon = reconstructStageRecords(swissMatches, stageNum); + stageResults: StageResultsMap | undefined, + recon: Map = reconstructStageRecords(swissMatches, stageNum) +): { + initialRecords: Map; + lockedEliminated: Set; + lockedAdvanced: Set; +} { const initialRecords = new Map(); const lockedEliminated = new Set(); + const lockedAdvanced = new Set(); const cs2ElimIds = new Set(); if (stageResults) { @@ -838,15 +874,17 @@ function buildStageInitialRecords( lockedEliminated.add(t.id); } else if (r && r.wins >= 3) { initialRecords.set(t.id, { wins: 3, losses: r.losses }); + lockedAdvanced.add(t.id); } else if (stageComplete) { // Stage done and this team wasn't eliminated → it advanced. Exact loss // count is unknown without match data; use 2 as a conservative fallback. initialRecords.set(t.id, { wins: 3, losses: r?.losses ?? 2 }); + lockedAdvanced.add(t.id); } else if (r) { initialRecords.set(t.id, { wins: r.wins, losses: r.losses }); } } - return { initialRecords, lockedEliminated }; + return { initialRecords, lockedEliminated, lockedAdvanced }; } /** @@ -857,19 +895,43 @@ function buildStageInitialRecords( * advancers (most losses, then worst rank) or promoting the strongest * eliminated teams (most wins, then best rank) — so downstream stages stay * well-formed. A no-op for the common, consistent case. + * + * Teams in `locked` come from real match data and must not be flipped: locked + * advancers are kept out of the demotion pool and locked-eliminated teams out + * of the promotion pool. They are only touched as a last resort, when there + * aren't enough non-locked teams to reach `target` (a genuinely inconsistent + * snapshot), in which case the weakest/strongest locked team is used. */ -function reconcileAdvancers(result: SwissResult, target: number): SwissResult { +export function reconcileAdvancers( + result: SwissResult, + target: number, + locked: Set = new Set() +): SwissResult { if (result.advanced.length === target) return result; const advanced = [...result.advanced]; const eliminated = [...result.eliminated]; if (advanced.length > target) { - advanced.sort((a, b) => (a.losses !== b.losses ? a.losses - b.losses : a.rank - b.rank)); + // Demote the weakest non-locked advancers first; sort locked teams to the + // front (kept) so they are demoted only if non-locked teams run out. + advanced.sort((a, b) => { + const la = locked.has(a.id) ? 1 : 0; + const lb = locked.has(b.id) ? 1 : 0; + if (la !== lb) return lb - la; // locked first → kept + return a.losses !== b.losses ? a.losses - b.losses : a.rank - b.rank; + }); for (const t of advanced.splice(target)) { eliminated.push({ id: t.id, elo: t.elo, rank: t.rank, wins: 2 }); } } else { - eliminated.sort((a, b) => (b.wins !== a.wins ? b.wins - a.wins : a.rank - b.rank)); + // Promote the strongest non-locked eliminated teams first; sort locked + // teams to the back so they are promoted only if non-locked teams run out. + eliminated.sort((a, b) => { + const la = locked.has(a.id) ? 1 : 0; + const lb = locked.has(b.id) ? 1 : 0; + if (la !== lb) return la - lb; // non-locked first → promoted + return b.wins !== a.wins ? b.wins - a.wins : a.rank - b.rank; + }); for (const t of eliminated.splice(0, target - advanced.length)) { advanced.push({ id: t.id, elo: t.elo, rank: t.rank, losses: 2 }); } @@ -899,10 +961,15 @@ export function simulateOneMajor( qpConfig: Map, options: SimulateOneMajorOptions = {} ): Map { - const { bracketMatches, swissMatches, recordedResults } = options; + const { bracketMatches, swissMatches, swissRecords, recordedResults } = options; const qpMap = new Map(); const poolById = new Map(pool.map((t) => [t.id, t])); + // Per-stage reconstructed records are invariant across iterations; reuse the + // caller's precomputed maps when available, otherwise derive on demand. + const reconFor = (stageNum: number) => + swissRecords?.get(stageNum) ?? reconstructStageRecords(swissMatches, stageNum); + // ── Determine field and stage assignments ───────────────────────────────── // stage2Direct / stage3Direct are AdvancedTeam[] (losses = 0: no prior Swiss stage) let stage1Teams: TeamWithElo[]; @@ -932,23 +999,27 @@ export function simulateOneMajor( } // ── Stage 1 (Opening) — all Bo1 ─────────────────────────────────────────── - // Each stage advances exactly 8 teams; reconcile guards against ragged inputs. - const s1Seed = buildStageInitialRecords(stage1Teams, 1, swissMatches, stageResults); - const stage1Result = reconcileAdvancers(simulateSwiss(stage1Teams, false, false, s1Seed.initialRecords), 8); + // Each stage advances exactly 8 teams; reconcile guards against ragged inputs + // while protecting teams whose result is locked in from real data. + const s1Seed = buildStageInitialRecords(stage1Teams, 1, swissMatches, stageResults, reconFor(1)); + const s1Locked = new Set([...s1Seed.lockedEliminated, ...s1Seed.lockedAdvanced]); + const stage1Result = reconcileAdvancers(simulateSwiss(stage1Teams, false, false, s1Seed.initialRecords), 8, s1Locked); const stage1Advanced = stage1Result.advanced; const stage1EliminatedFinal = stage1Result.eliminated; // ── Stage 2 (Challengers) — Bo1, Bo3 for decisive matches ──────────────── const stage2Teams: AdvancedTeam[] = [...stage2Direct, ...stage1Advanced]; - const s2Seed = buildStageInitialRecords(stage2Teams, 2, swissMatches, stageResults); - const stage2Result = reconcileAdvancers(simulateSwiss(stage2Teams, false, true, s2Seed.initialRecords), 8); + const s2Seed = buildStageInitialRecords(stage2Teams, 2, swissMatches, stageResults, reconFor(2)); + const s2Locked = new Set([...s2Seed.lockedEliminated, ...s2Seed.lockedAdvanced]); + const stage2Result = reconcileAdvancers(simulateSwiss(stage2Teams, false, true, s2Seed.initialRecords), 8, s2Locked); const stage2Advanced = stage2Result.advanced; const stage2EliminatedFinal = stage2Result.eliminated; // ── Stage 3 (Legends) — all Bo3 ────────────────────────────────────────── const stage3Teams: AdvancedTeam[] = [...stage3Direct, ...stage2Advanced]; - const s3Seed = buildStageInitialRecords(stage3Teams, 3, swissMatches, stageResults); - const stage3Result = reconcileAdvancers(simulateSwiss(stage3Teams, true, false, s3Seed.initialRecords), 8); + const s3Seed = buildStageInitialRecords(stage3Teams, 3, swissMatches, stageResults, reconFor(3)); + const s3Locked = new Set([...s3Seed.lockedEliminated, ...s3Seed.lockedAdvanced]); + const stage3Result = reconcileAdvancers(simulateSwiss(stage3Teams, true, false, s3Seed.initialRecords), 8, s3Locked); const stage3EliminatedFinal = stage3Result.eliminated; // Stage 3 reconciles to exactly 8 advancers — the Champions Stage field.