Fix Swiss algorithm assigning bye to top team

This commit is contained in:
Kalle
2026-08-21 18:40:21 +03:00
parent 61a79719de
commit 4c68c5bdcf
3 changed files with 73 additions and 4 deletions

View File

@@ -3,6 +3,7 @@ import type { TournamentStageSettings } from "~/db/tables-json";
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
LOW_INK_AUGUST_2025,
LOW_INK_AUGUST_2026_ROUND_3,
RUSH_WEEKEND_3,
} from "~/features/tournament-bracket/core/tests/mocks-swiss";
import { ZONES_WEEKLY_38 } from "~/features/tournament-bracket/core/tests/mocks-zones-weekly";
@@ -232,7 +233,11 @@ describe("Swiss", () => {
});
});
const PAIR_UP_TEST_CASES = [RUSH_WEEKEND_3, LOW_INK_AUGUST_2025];
const PAIR_UP_TEST_CASES = [
RUSH_WEEKEND_3,
LOW_INK_AUGUST_2025,
LOW_INK_AUGUST_2026_ROUND_3,
];
describe("pairUp()", () => {
test.for(PAIR_UP_TEST_CASES)(
@@ -376,6 +381,23 @@ describe("Swiss", () => {
expect(bye?.opponentOne).toBe(2);
});
test("gives the bye to the lowest score group even when byeing a top group team would keep every pairing within its score group", () => {
// real state where the top score group had an odd size (7) while the
// lower groups were even (16 and 6): byeing an undefeated team lets
// every match stay within its score group, which the weights preferred:
// live this gave the free win to the 2-0 top seed instead of a 0-2 team
const result = Swiss.pairUp(LOW_INK_AUGUST_2026_ROUND_3);
const bye = result.find((match) => match.opponentTwo === null);
invariant(bye, "bye not found");
const byeTeam = LOW_INK_AUGUST_2026_ROUND_3.find(
(team) => team.id === bye.opponentOne,
);
expect(byeTeam?.score).toBe(0);
});
});
describe("calculateTeamStatus()", () => {

View File

@@ -16,7 +16,7 @@ import { calculateTeamStatus } from "./team-status";
* to dwarf every other weight of the whole round, meaning rematches only happen when
* unavoidable and then as few of them as possible.
*/
const REMATCH_PENALTY = 1_000_000;
const REMATCH_PENALTY = 1_000_000_000;
/**
* Weight added for each team of a pair that has already received a bye, making
@@ -24,9 +24,21 @@ const REMATCH_PENALTY = 1_000_000;
* so that a matching leaving a bye-less team unpaired always beats one giving a team
* a second bye: with an odd team count exactly one team is left unpaired, so the
* matchings differ by exactly one bonus, which is set to dwarf every score-based
* weight while staying far below REMATCH_PENALTY.
* weight (including HIGH_SCORE_PAIRING_BONUS) while staying far below
* REMATCH_PENALTY.
*/
const PRIOR_BYE_PAIRING_BONUS = 10_000;
const PRIOR_BYE_PAIRING_BONUS = 10_000_000;
/**
* Weight added per score point of both teams of a pair. Summed over a full matching
* this contributes the bonus times the total score of all paired teams, so with an
* odd team count the matching maximizes it by leaving a lowest-score team unpaired —
* that team receives the bye. Without this term the bye can drift to a top score
* group team whenever the score group size parities make all-within-group pairings
* possible only that way. Sized to dwarf the pairing quality weights (< ~100 per
* pair) while a full round's worth stays far below PRIOR_BYE_PAIRING_BONUS.
*/
const HIGH_SCORE_PAIRING_BONUS = 10_000;
interface GroupArgs {
groupId: number;
@@ -295,6 +307,8 @@ function generateWeightedPairs({
wt -= 10;
}
wt += (curr.score + opp.score) * HIGH_SCORE_PAIRING_BONUS;
if (curr.receivedBye) {
wt += PRIOR_BYE_PAIRING_BONUS;
}

View File

@@ -19,6 +19,39 @@ export const RUSH_WEEKEND_3 = [
{ id: 39814, score: 0, receivedBye: false, avoid: [38337, 39850] },
];
// tournament 4224 group A entering round 3: 7 teams at score 2, 16 at score 1, 6 at score 0
export const LOW_INK_AUGUST_2026_ROUND_3 = [
{ id: 83693, score: 2, receivedBye: false, avoid: [85574, 84491] },
{ id: 84224, score: 2, receivedBye: false, avoid: [86019, 85541] },
{ id: 84735, score: 2, receivedBye: false, avoid: [84239, 83733] },
{ id: 85791, score: 2, receivedBye: false, avoid: [85868, 85393] },
{ id: 85832, score: 2, receivedBye: false, avoid: [83873, 84857] },
{ id: 85909, score: 2, receivedBye: false, avoid: [86038, 86046] },
{ id: 85970, score: 2, receivedBye: false, avoid: [85408, 85347] },
{ id: 83619, score: 1, receivedBye: false, avoid: [83733, 85775] },
{ id: 83733, score: 1, receivedBye: false, avoid: [83619, 84735] },
{ id: 84491, score: 1, receivedBye: false, avoid: [84553, 83693] },
{ id: 84553, score: 1, receivedBye: false, avoid: [84491, 86019] },
{ id: 84857, score: 1, receivedBye: false, avoid: [86067, 85832] },
{ id: 85347, score: 1, receivedBye: false, avoid: [85504, 85970] },
{ id: 85393, score: 1, receivedBye: false, avoid: [85860, 85791] },
{ id: 85408, score: 1, receivedBye: false, avoid: [85970, 85697] },
{ id: 85536, score: 1, receivedBye: true, avoid: [86067] },
{ id: 85541, score: 1, receivedBye: false, avoid: [85697, 84224] },
{ id: 85574, score: 1, receivedBye: false, avoid: [83693, 84239] },
{ id: 85860, score: 1, receivedBye: true, avoid: [85393] },
{ id: 85868, score: 1, receivedBye: false, avoid: [85791, 85504] },
{ id: 86038, score: 1, receivedBye: false, avoid: [85909, 83873] },
{ id: 86046, score: 1, receivedBye: false, avoid: [85775, 85909] },
{ id: 86067, score: 1, receivedBye: false, avoid: [84857, 85536] },
{ id: 83873, score: 0, receivedBye: false, avoid: [85832, 86038] },
{ id: 84239, score: 0, receivedBye: false, avoid: [84735, 85574] },
{ id: 85504, score: 0, receivedBye: false, avoid: [85347, 85868] },
{ id: 85697, score: 0, receivedBye: false, avoid: [85541, 85408] },
{ id: 85775, score: 0, receivedBye: false, avoid: [86046, 83619] },
{ id: 86019, score: 0, receivedBye: false, avoid: [84224, 84553] },
];
export const LOW_INK_AUGUST_2025 = [
{ id: 40878, score: 2, receivedBye: false, avoid: [39949, 41092] },
{ id: 40986, score: 2, receivedBye: false, avoid: [41308, 40796] },