Harden CS2 simulator: protect locked results, gate recorded bracket, hoist recon
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m50s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 49s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

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:
Claude 2026-06-20 03:17:30 +00:00
parent 8990bf4281
commit 2420cf3c19
No known key found for this signature in database
2 changed files with 182 additions and 34 deletions

View file

@ -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) ──────

View file

@ -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 WL 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 (00 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.