Harden CS2 simulator: protect locked results, gate recorded bracket, hoist recon
Three review follow-ups on the conditioning change: - reconcileAdvancers no longer flips a result that is locked in from real match data. 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 the 8-advancer target (a genuinely inconsistent snapshot). buildStageInitialRecords now also returns the lockedAdvanced set for this. - simulateChampionsStage only honors recorded bracket results when the real bracket pairings are actually in use (realBracket). Previously a recorded winner could be applied to a fictitious Elo-seeded pairing. - Per-stage Swiss W-L reconstruction is loop-invariant, so it is now computed once per event in CSMajorSimulator.simulate and passed in via options.swissRecords instead of being re-derived on every Monte Carlo iteration. Adds direct unit coverage for reconcileAdvancers (protection + last-resort fallback) and for the bracket-gating behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zMG6JFng8bMqZYweffVuE
This commit is contained in:
parent
8990bf4281
commit
2420cf3c19
2 changed files with 182 additions and 34 deletions
|
|
@ -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) ──────
|
||||
|
|
|
|||
|
|
@ -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<number, Map<string, { wins: number; losses: number }>>;
|
||||
/** participantId → QP already recorded in event_results (provisional/locked). */
|
||||
recordedResults?: Map<string, number>;
|
||||
}
|
||||
|
|
@ -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<number, Map<string, { wins: number; losses: number }>>
|
||||
>(
|
||||
[...eventSwiss].map(([id, matches]) => [
|
||||
id,
|
||||
new Map([1, 2, 3].map((stageNum) => [stageNum, reconstructStageRecords(matches, stageNum)])),
|
||||
])
|
||||
);
|
||||
|
||||
const eventRecordedQP = new Map<string, Map<string, number>>();
|
||||
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<string, { wins: number; losses: number }>; lockedEliminated: Set<string> } {
|
||||
const recon = reconstructStageRecords(swissMatches, stageNum);
|
||||
stageResults: StageResultsMap | undefined,
|
||||
recon: Map<string, { wins: number; losses: number }> = reconstructStageRecords(swissMatches, stageNum)
|
||||
): {
|
||||
initialRecords: Map<string, { wins: number; losses: number }>;
|
||||
lockedEliminated: Set<string>;
|
||||
lockedAdvanced: Set<string>;
|
||||
} {
|
||||
const initialRecords = new Map<string, { wins: number; losses: number }>();
|
||||
const lockedEliminated = new Set<string>();
|
||||
const lockedAdvanced = new Set<string>();
|
||||
|
||||
const cs2ElimIds = new Set<string>();
|
||||
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<string> = 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<number, number>,
|
||||
options: SimulateOneMajorOptions = {}
|
||||
): Map<string, number> {
|
||||
const { bracketMatches, swissMatches, recordedResults } = options;
|
||||
const { bracketMatches, swissMatches, swissRecords, recordedResults } = options;
|
||||
const qpMap = new Map<string, number>();
|
||||
const poolById = new Map<string, TeamWithElo>(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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue