Spaces:
Paused
Fix two real bugs in Tier 5 Swiss tournament found by belated review
Browse filesA scripting accident crashed the original orchestration before Tier 5 got
the same independent verify+review every other tier received, so it shipped
self-graded. Dispatching two independent agents after the fact (a verifier
and an adversarial reviewer) found two real problems the self-grading
missed, plus one instance of an already-known bug class:
1. HIGH: a bye credited worlds.length x seeds x 2 (a maximum-margin sweep)
as pure wins with zero losses -- reproduced live tying a candidate that
structurally cannot fight with a genuine winner, and confirmed that
credit exceeded the best real margin observed across the project's own
strategy roster. Fixed: a bye now adds only to its own `byes` counter,
never wins/losses.
2. MEDIUM: pairRound's own comment and CLI text claimed a repeat pairing
only happens when every opponent has already been played. False --
fuzz-tested against a brute-force checker, the old greedy-no-backtracking
walk produced an avoidable repeat in 25-51% of realistic tournaments
(n=6..50), and the shipped "rematch avoidance" test only passed because
its hardcoded seed happened to be lucky. Fixed: pairRound now backtracks
(findMatching, budget-bounded) to find a genuine zero-repeat matching
whenever one exists. Re-verified at the same scales the review used:
990 rounds, zero avoidable repeats (down from 25-51%).
3. low: the difficulty-echo bug fixed elsewhere in runDuel existed twice
more, unpatched, in runSwissBracket/runRoundRobinSwapped's own
bracket-level field.
Also corrects the search ledger's 2026-08-03 Swiss row, which repeated the
original implementation's false "rematches avoided" claim -- the ledger
now records what actually happened, including the correction itself.
1893 -> 1897 tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmxiQ6BxmWQRAF6BGjZTn
- docs/odyssey-ai-review.md +3 -2
- test/ailab.test.js +106 -18
- tools/ailab.js +103 -41
|
@@ -627,5 +627,6 @@ re-run.
|
|
| 627 |
| 2026-08-03 | a fair `duel` command (`tools/ailab.js`) can be built directly on the self-play refactor, reusing `engine/victory.js`'s own resolution rather than inventing one | `pinnedDuelDials()` reads difficulty once into one object both sides spread from; `runDuel`/`runRoundRobin` | 1860β1867 (7 new tests). Independent review found a **second real high-severity bug**: `duel` always seated candidate A as `"player"` and B as `"ai"`, so on a world with a map-baked `asym` block (`oort`, `nimbus`) the fixed side decided the match β two *identical* candidates measured 6W-0L on oort and 1W-5L on nimbus. Fixed by alternating `swapAsym` by replicate parity; the same repro then measured 4-2 / 3-3, consistent with ordinary variance | **yes** |
|
| 628 |
| 2026-08-03 | side-swap (both candidates play both owner slots) and never-blended difficulty brackets close the remaining "is one owner slot structurally easier" and "does averaging across difficulty hide an APM-driven edge" gaps `duel` alone couldn't rule out | `runSwappedDuel`/`runDuelBrackets`/`runRoundRobinSwapped`, side-swap on by default (no opt-out flag, "not an opt-in easy to forget") | 1867β1875 (8 new tests). Review found only non-blocking issues (a cosmetic aggregate-level difficulty-echo bug carried over from `duel`, and a documented note that the two swap directions sample different seeds so a disagreement between them isn't *purely* an owner-slot signal) β no fix cycle needed, passed on first review | **yes** |
|
| 629 |
| 2026-08-03 | `search`'s existing coordinate-scan can evaluate candidates by tournament standing instead of solo `score()` against a scripted bot, without changing the scan mechanics or the non-tournament path | `--tournament-against baseline.json` switches `evaluate()` to `runDuelBrackets` against a fixed baseline; `mode`/`detail` shape distinguishes tournament rows from score rows | 1875β1880 (5 new tests). Verifier independently re-ran `runDuelBrackets` on `search`'s own winning candidate and diffed against `search`'s internal result β byte-identical, proving genuine delegation rather than a look-alike calculation. One cosmetic-only finding (an advisory CLI line unconditionally reworded) | **yes** |
|
| 630 |
-
| 2026-08-03 | Swiss pairing (closest-standing pairs, rotated byes, `max(3, βlogβnβ)` rounds) is the right bounded Tier 5 slice β not an ELO ladder (needs cross-invocation persistence this project has no use for yet) or Odyssey multi-owner (needs the refactor generalized past two slots, out of proportion for a scheduling layer) | `runSwissTournament`, reusing `runSwappedDuel` unchanged as the per-pairing primitive |
|
| 631 |
-
| 2026-08-03 | the
|
|
|
|
|
|
| 627 |
| 2026-08-03 | a fair `duel` command (`tools/ailab.js`) can be built directly on the self-play refactor, reusing `engine/victory.js`'s own resolution rather than inventing one | `pinnedDuelDials()` reads difficulty once into one object both sides spread from; `runDuel`/`runRoundRobin` | 1860β1867 (7 new tests). Independent review found a **second real high-severity bug**: `duel` always seated candidate A as `"player"` and B as `"ai"`, so on a world with a map-baked `asym` block (`oort`, `nimbus`) the fixed side decided the match β two *identical* candidates measured 6W-0L on oort and 1W-5L on nimbus. Fixed by alternating `swapAsym` by replicate parity; the same repro then measured 4-2 / 3-3, consistent with ordinary variance | **yes** |
|
| 628 |
| 2026-08-03 | side-swap (both candidates play both owner slots) and never-blended difficulty brackets close the remaining "is one owner slot structurally easier" and "does averaging across difficulty hide an APM-driven edge" gaps `duel` alone couldn't rule out | `runSwappedDuel`/`runDuelBrackets`/`runRoundRobinSwapped`, side-swap on by default (no opt-out flag, "not an opt-in easy to forget") | 1867β1875 (8 new tests). Review found only non-blocking issues (a cosmetic aggregate-level difficulty-echo bug carried over from `duel`, and a documented note that the two swap directions sample different seeds so a disagreement between them isn't *purely* an owner-slot signal) β no fix cycle needed, passed on first review | **yes** |
|
| 629 |
| 2026-08-03 | `search`'s existing coordinate-scan can evaluate candidates by tournament standing instead of solo `score()` against a scripted bot, without changing the scan mechanics or the non-tournament path | `--tournament-against baseline.json` switches `evaluate()` to `runDuelBrackets` against a fixed baseline; `mode`/`detail` shape distinguishes tournament rows from score rows | 1875β1880 (5 new tests). Verifier independently re-ran `runDuelBrackets` on `search`'s own winning candidate and diffed against `search`'s internal result β byte-identical, proving genuine delegation rather than a look-alike calculation. One cosmetic-only finding (an advisory CLI line unconditionally reworded) | **yes** |
|
| 630 |
+
| 2026-08-03 | Swiss pairing (closest-standing pairs, rotated byes, `max(3, βlogβnβ)` rounds) is the right bounded Tier 5 slice β not an ELO ladder (needs cross-invocation persistence this project has no use for yet) or Odyssey multi-owner (needs the refactor generalized past two slots, out of proportion for a scheduling layer) | `runSwissTournament`, reusing `runSwappedDuel` unchanged as the per-pairing primitive | **Correction below (same date) β this row's own "independently verified" claim was false; see the next row.** A scripting accident in the orchestration crashed before Tier 5 got the same independent verify+review every other tier in this table got; it shipped self-graded (1880β1893, 13 new tests, all green) with nobody else checking it first | **partially β see below** |
|
| 631 |
+
| 2026-08-03 | the row above claimed independent verification Tier 5 never actually received β dispatched two fresh, independent agents (a verifier and an adversarial reviewer, same as every other tier) after the fact to check it for real | β | Both found real problems the self-grading missed. **(1) HIGH:** a bye credited `worlds.length Γ seeds Γ 2` (a maximum-margin sweep) as pure wins with zero losses β reproduced live tying a candidate that structurally cannot fight (`neverInitiates` + a zero standing-army cap) with a genuine winner, and confirmed that credit exceeded the best real margin observed across every pairing of the project's own 4 named strategies (best real: 7/8; bye credit: 8/8). **(2) MEDIUM:** the pairing algorithm's own comment and CLI text claimed a repeat pairing only happens when "every remaining opponent has already been played" β false: fuzz-tested against a brute-force ground-truth checker, a plain greedy walk (first-fit, no backtracking) produced an *avoidable* repeat in 25-51% of realistic tournaments at n=6..50, and the shipped "rematch avoidance" test only passed because its hardcoded `seedBase` (7) happened to be lucky (6/9/10 reproducibly failed). **(3) low:** the same difficulty-echo bug class fixed elsewhere that day existed twice more, unpatched, in `runSwissBracket`/`runRoundRobinSwapped`'s own bracket-level field | **yes β this is what the row above should have said** |
|
| 632 |
+
| 2026-08-03 | fix all three, properly this time: a bye must be worth nothing, and rematch-avoidance must actually hold, not just usually | Bye: removed the wins credit entirely β a bye now adds only to its own `byes` counter, never `wins`/`losses` (renamed `byePoints`β`byeMatchCount`, kept as informational-only). Pairing: `pairRound` now backtracks (`findMatching`, bounded by `MATCH_ATTEMPT_CAP`) to find a genuine zero-repeat matching whenever one exists, falling back to the old greedy pass (which allows repeats) only when none does. Difficulty echo: both remaining instances now read through `pinnedDuelDials` | Fuzz-verified the pairing fix at the exact scales the review found failing (n=4..50, realistic sequential simulation, 990 rounds total): **0 avoidable repeats**, down from the review's measured 25-51%. The bye fix directly closes the reviewer's own repro (a non-fighting candidate can no longer tie a genuine winner). Full suite 1893β1897 (new: a direct backtracking-vs-greedy regression test reproducing the reviewer's exact stranding scenario, a genuine-impossibility fallback test, the bye-neutrality tests, and a strengthened rematch-avoidance test run across 5 seed bases including the ones that broke the old algorithm) | **yes** |
|
|
@@ -26,7 +26,7 @@ import { join } from "node:path";
|
|
| 26 |
import {
|
| 27 |
run, labWorld, summarise, score, applyOverrides, CHECKS, OPPONENTS, WORLDS, WEIGHTS, runLeaderboard,
|
| 28 |
runDuel, runRoundRobin, pinnedDuelDials, runSwappedDuel, runDuelBrackets, runRoundRobinSwapped, runSearch,
|
| 29 |
-
runSwissTournament,
|
| 30 |
} from "../tools/ailab.js";
|
| 31 |
import { STRATEGIES } from "../engine/aiStrategy.js";
|
| 32 |
import { DIFFICULTY_OPTIONS } from "../engine/aiDifficulty.js";
|
|
@@ -768,41 +768,108 @@ test("byes rotate: nobody gets a second bye while another candidate hasn't had o
|
|
| 768 |
assert.equal(new Set(byes).size, 5, `every candidate should get exactly one bye across 5 rounds of 5, got: ${byes.join(", ")}`);
|
| 769 |
});
|
| 770 |
|
| 771 |
-
test("a bye is
|
|
|
|
|
|
|
|
|
|
| 772 |
const candidates = Array.from({ length: 3 }, (_, i) => ({ name: `C${i}`, strategy: "default" }));
|
| 773 |
const opts = shortSwiss({ worlds: ["ferros", "vesper"], seeds: 2 });
|
| 774 |
const [bracket] = runSwissTournament(candidates, opts);
|
| 775 |
-
const expected = opts.worlds.length * opts.seeds * 2; // side-swapped -> both directions
|
| 776 |
-
assert.equal(bracket.byePoints, expected, "byePoints must equal worlds.length * seeds * 2, the n a real pairing would produce");
|
| 777 |
const round = bracket.roundsLog.find(r => r.byeName);
|
| 778 |
assert.ok(round, "fixture: an odd pool must produce a bye somewhere");
|
| 779 |
-
|
| 780 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 781 |
});
|
| 782 |
|
| 783 |
test("rematch avoidance: with enough candidates relative to rounds, no pairing repeats", () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
const candidates = Array.from({ length: 6 }, (_, i) => ({ name: `C${i}`, strategy: "default" }));
|
| 785 |
-
const
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
for (const
|
| 789 |
-
const
|
| 790 |
-
|
| 791 |
-
|
|
|
|
|
|
|
|
|
|
| 792 |
}
|
| 793 |
}
|
| 794 |
});
|
| 795 |
|
| 796 |
-
test("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
// Every REAL pairing is win/loss-balanced (one side's win is the other's loss, exactly like
|
| 798 |
-
// round-robin's own invariant)
|
|
|
|
| 799 |
const candidates = Array.from({ length: 5 }, (_, i) => ({ name: `C${i}`, strategy: "default" }));
|
| 800 |
const [bracket] = runSwissTournament(candidates, shortSwiss());
|
| 801 |
const totalWins = bracket.standings.reduce((s, c) => s + c.wins, 0);
|
| 802 |
const totalLosses = bracket.standings.reduce((s, c) => s + c.losses, 0);
|
| 803 |
-
|
| 804 |
-
assert.equal(totalWins - totalLosses, byeCredit,
|
| 805 |
-
"the only win with no corresponding loss anywhere is a bye's own credit");
|
| 806 |
});
|
| 807 |
|
| 808 |
test("standings are sorted most-wins-first (ties broken by fewer losses)", () => {
|
|
@@ -841,6 +908,27 @@ test("--difficulties (plural) runs an independent Swiss tournament per difficult
|
|
| 841 |
"the two difficulty brackets must be independently-run tournaments, not one relabelled as two");
|
| 842 |
});
|
| 843 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
test("a Swiss tournament's overrides never leak into what runs next", () => {
|
| 845 |
const before = JSON.stringify(STRATEGIES.aggressive);
|
| 846 |
const candidates = [
|
|
|
|
| 26 |
import {
|
| 27 |
run, labWorld, summarise, score, applyOverrides, CHECKS, OPPONENTS, WORLDS, WEIGHTS, runLeaderboard,
|
| 28 |
runDuel, runRoundRobin, pinnedDuelDials, runSwappedDuel, runDuelBrackets, runRoundRobinSwapped, runSearch,
|
| 29 |
+
runSwissTournament, pairRound,
|
| 30 |
} from "../tools/ailab.js";
|
| 31 |
import { STRATEGIES } from "../engine/aiStrategy.js";
|
| 32 |
import { DIFFICULTY_OPTIONS } from "../engine/aiDifficulty.js";
|
|
|
|
| 768 |
assert.equal(new Set(byes).size, 5, `every candidate should get exactly one bye across 5 rounds of 5, got: ${byes.join(", ")}`);
|
| 769 |
});
|
| 770 |
|
| 771 |
+
test("a bye is scorable-neutral: it adds to nobody's wins or losses, only its own bye count", () => {
|
| 772 |
+
// A bye is a candidate the schedule couldn't pair, not a match anyone won β crediting it as a
|
| 773 |
+
// win (at ANY size) lets a candidate that never fights outrank one that actually won something.
|
| 774 |
+
// The only thing a bye should move is `byes` (bookkeeping/reporting), never `wins`/`losses`.
|
| 775 |
const candidates = Array.from({ length: 3 }, (_, i) => ({ name: `C${i}`, strategy: "default" }));
|
| 776 |
const opts = shortSwiss({ worlds: ["ferros", "vesper"], seeds: 2 });
|
| 777 |
const [bracket] = runSwissTournament(candidates, opts);
|
|
|
|
|
|
|
| 778 |
const round = bracket.roundsLog.find(r => r.byeName);
|
| 779 |
assert.ok(round, "fixture: an odd pool must produce a bye somewhere");
|
| 780 |
+
// byeMatchCount is purely informational β "how big a real pairing this round would have been" β
|
| 781 |
+
// and must still be reported accurately, just never added to anyone's standing.
|
| 782 |
+
const expectedMatchCount = opts.worlds.length * opts.seeds * 2;
|
| 783 |
+
assert.equal(bracket.byeMatchCount, expectedMatchCount);
|
| 784 |
+
assert.equal(round.byeMatchCount, expectedMatchCount);
|
| 785 |
+
});
|
| 786 |
+
|
| 787 |
+
test("a bye never lets a candidate that structurally cannot fight tie or beat one that actually won", () => {
|
| 788 |
+
// The exact scenario an independent review found broken: an odd field where one candidate is
|
| 789 |
+
// neverInitiates + a zero standing-army cap (cannot ever field a fight in skirmish) draws a bye,
|
| 790 |
+
// and a genuine winner must still rank strictly above it β a bye must never be worth what a real
|
| 791 |
+
// win is worth.
|
| 792 |
+
const normal = { name: "normal", strategy: "default" };
|
| 793 |
+
const crippled = (i) => ({
|
| 794 |
+
name: `crippled${i}`, strategy: `labByeCrippled${i}`,
|
| 795 |
+
overrides: { strategies: { [`labByeCrippled${i}`]: { neverInitiates: true, standingArmyCap: 0 } } },
|
| 796 |
+
});
|
| 797 |
+
// 3 candidates (odd): normal beats one crippled candidate in a real match, the OTHER crippled
|
| 798 |
+
// candidate draws the round's bye and therefore never fights anyone at all.
|
| 799 |
+
const candidates = [normal, crippled(1), crippled(2)];
|
| 800 |
+
const opts = shortSwiss({ worlds: ["korrath"], minutes: 15, seeds: 1, rounds: 1 });
|
| 801 |
+
const [bracket] = runSwissTournament(candidates, opts);
|
| 802 |
+
const byeRecipient = bracket.roundsLog[0].byeName;
|
| 803 |
+
assert.ok(byeRecipient, "fixture: 3 candidates is odd, a bye must fire");
|
| 804 |
+
const byeRow = bracket.standings.find(s => s.name === byeRecipient);
|
| 805 |
+
const normalRow = bracket.standings.find(s => s.name === "normal");
|
| 806 |
+
assert.equal(byeRow.wins, 0, "a candidate that never fought must have zero wins, bye or not");
|
| 807 |
+
assert.ok(normalRow.wins > byeRow.wins,
|
| 808 |
+
`the genuine winner (${normalRow.wins} wins) must rank strictly above a bye recipient that never fought (${byeRow.wins} wins)`);
|
| 809 |
});
|
| 810 |
|
| 811 |
test("rematch avoidance: with enough candidates relative to rounds, no pairing repeats", () => {
|
| 812 |
+
// A plain greedy pairing (this test's first version) could strand two candidates together even
|
| 813 |
+
// when a full zero-repeat matching existed elsewhere in the round β independent review found it
|
| 814 |
+
// failing 25-51% of the time at these exact scales, and that this very test only passed because
|
| 815 |
+
// its hardcoded seedBase (7) happened to be a lucky one (seedBases 6, 9, 10 reproducibly failed
|
| 816 |
+
// with the old algorithm). pairRound now backtracks to find a zero-repeat matching whenever one
|
| 817 |
+
// exists, so this checks across several seed bases β including the ones independently confirmed
|
| 818 |
+
// to break the old algorithm β rather than trusting a single roll.
|
| 819 |
const candidates = Array.from({ length: 6 }, (_, i) => ({ name: `C${i}`, strategy: "default" }));
|
| 820 |
+
for (const seedBase of [1, 6, 7, 9, 10]) {
|
| 821 |
+
const [bracket] = runSwissTournament(candidates, shortSwiss({ rounds: 3, seedBase }));
|
| 822 |
+
const seen = new Set();
|
| 823 |
+
for (const { matches } of bracket.roundsLog) {
|
| 824 |
+
for (const res of matches) {
|
| 825 |
+
const key = [res.aName, res.bName].sort().join("::");
|
| 826 |
+
assert.ok(!seen.has(key),
|
| 827 |
+
`seedBase=${seedBase}: pairing ${key} repeated across rounds even though unplayed opponents remained (6 candidates, 3 rounds)`);
|
| 828 |
+
seen.add(key);
|
| 829 |
+
}
|
| 830 |
}
|
| 831 |
}
|
| 832 |
});
|
| 833 |
|
| 834 |
+
test("pairRound backtracks to a zero-repeat matching a plain greedy walk would miss", () => {
|
| 835 |
+
// The exact failure an independent review reproduced: greedily pairing the two joint leaders
|
| 836 |
+
// first strands the round's one forbidden pair together, even though pairing either leader with
|
| 837 |
+
// one of them instead leaves a perfectly valid zero-repeat matching for the rest. Tested directly
|
| 838 |
+
// against pairRound on synthetic standings β fast, and isolates the pairing algorithm from match
|
| 839 |
+
// simulation entirely.
|
| 840 |
+
const standings = [
|
| 841 |
+
{ name: "Aggressive", wins: 3, losses: 1 }, { name: "QuickCommit", wins: 3, losses: 1 },
|
| 842 |
+
{ name: "Adaptive", wins: 1, losses: 3 }, { name: "ForceParity", wins: 1, losses: 3 },
|
| 843 |
+
];
|
| 844 |
+
const played = new Set(["Adaptive::ForceParity"]);
|
| 845 |
+
const { pairs } = pairRound(standings, played, new Set());
|
| 846 |
+
const pairKeys = pairs.map(([a, b]) => [a.name, b.name].sort().join("::"));
|
| 847 |
+
assert.equal(pairs.length, 2, "4 candidates must produce 2 pairs");
|
| 848 |
+
assert.ok(!pairKeys.includes("Adaptive::ForceParity"),
|
| 849 |
+
`pairRound repeated the one forbidden pair even though a zero-repeat matching existed: got ${pairKeys.join(", ")}`);
|
| 850 |
+
});
|
| 851 |
+
|
| 852 |
+
test("pairRound still completes (with a forced repeat) when a zero-repeat matching is genuinely impossible", () => {
|
| 853 |
+
// 4 candidates who have all already played each other (a fully round-robin'd played-set) leave
|
| 854 |
+
// no zero-repeat option anywhere β pairRound must still return a complete pairing (via its
|
| 855 |
+
// greedy fallback) rather than hang or throw.
|
| 856 |
+
const standings = ["A", "B", "C", "D"].map(name => ({ name, wins: 0, losses: 0 }));
|
| 857 |
+
const played = new Set(["A::B", "A::C", "A::D", "B::C", "B::D", "C::D"]);
|
| 858 |
+
const { pairs } = pairRound(standings, played, new Set());
|
| 859 |
+
assert.equal(pairs.length, 2, "must still produce a complete pairing even with no zero-repeat option");
|
| 860 |
+
const paired = new Set(pairs.flatMap(([a, b]) => [a.name, b.name]));
|
| 861 |
+
assert.equal(paired.size, 4, "every candidate must appear in exactly one pair");
|
| 862 |
+
});
|
| 863 |
+
|
| 864 |
+
test("Swiss standings: total wins equal total losses β a bye contributes to neither", () => {
|
| 865 |
// Every REAL pairing is win/loss-balanced (one side's win is the other's loss, exactly like
|
| 866 |
+
// round-robin's own invariant), and a bye is now scorable-neutral (previous test) β so unlike a
|
| 867 |
+
// points-based Swiss ladder, there is no unmatched credit anywhere in these standings at all.
|
| 868 |
const candidates = Array.from({ length: 5 }, (_, i) => ({ name: `C${i}`, strategy: "default" }));
|
| 869 |
const [bracket] = runSwissTournament(candidates, shortSwiss());
|
| 870 |
const totalWins = bracket.standings.reduce((s, c) => s + c.wins, 0);
|
| 871 |
const totalLosses = bracket.standings.reduce((s, c) => s + c.losses, 0);
|
| 872 |
+
assert.equal(totalWins, totalLosses, "every win must have a matching loss somewhere β a bye must add to neither");
|
|
|
|
|
|
|
| 873 |
});
|
| 874 |
|
| 875 |
test("standings are sorted most-wins-first (ties broken by fewer losses)", () => {
|
|
|
|
| 908 |
"the two difficulty brackets must be independently-run tournaments, not one relabelled as two");
|
| 909 |
});
|
| 910 |
|
| 911 |
+
test("an unknown difficulty is normalized everywhere it's reported, not just where matches run: duel, round-robin, Swiss", () => {
|
| 912 |
+
// pinnedDuelDials() already normalizes an unknown/typo'd difficulty to "medium" for apm/micro,
|
| 913 |
+
// and every match row reports that normalized value β but three separate top-level aggregate
|
| 914 |
+
// fields each independently echoed the raw input instead: runDuel's own return, and (found by a
|
| 915 |
+
// later independent review of this same difficulty-echo bug CLASS) runRoundRobinSwapped's and
|
| 916 |
+
// runSwissBracket's per-bracket `.difficulty` field. All three must agree with what actually ran.
|
| 917 |
+
const a = { name: "A", strategy: "default" }, b = { name: "B", strategy: "aggressive" };
|
| 918 |
+
const c = { name: "C", strategy: "economic" };
|
| 919 |
+
const short = { worlds: ["ferros"], seeds: 1, minutes: 3 };
|
| 920 |
+
const bogus = "totallyBogusTypo";
|
| 921 |
+
|
| 922 |
+
const duelRes = runDuel(a, b, { ...short, difficulty: bogus });
|
| 923 |
+
assert.equal(duelRes.difficulty, "medium", "runDuel's aggregate difficulty must be normalized, not the raw input");
|
| 924 |
+
|
| 925 |
+
const [rrBracket] = runRoundRobinSwapped([a, b, c], { ...short, difficulty: bogus });
|
| 926 |
+
assert.equal(rrBracket.difficulty, "medium", "runRoundRobinSwapped's bracket difficulty must be normalized, not the raw input");
|
| 927 |
+
|
| 928 |
+
const [swissBracket] = runSwissTournament([a, b, c], { ...short, difficulty: bogus });
|
| 929 |
+
assert.equal(swissBracket.difficulty, "medium", "runSwissTournament's bracket difficulty must be normalized, not the raw input");
|
| 930 |
+
});
|
| 931 |
+
|
| 932 |
test("a Swiss tournament's overrides never leak into what runs next", () => {
|
| 933 |
const before = JSON.stringify(STRATEGIES.aggressive);
|
| 934 |
const candidates = [
|
|
@@ -841,7 +841,10 @@ export function runRoundRobinSwapped(candidates, opts = {}) {
|
|
| 841 |
A.wins += res.aWins; A.losses += res.bWins; A.draws += res.draws;
|
| 842 |
B.wins += res.bWins; B.losses += res.aWins; B.draws += res.draws;
|
| 843 |
}
|
| 844 |
-
return {
|
|
|
|
|
|
|
|
|
|
| 845 |
});
|
| 846 |
}
|
| 847 |
|
|
@@ -862,24 +865,36 @@ export function runRoundRobinSwapped(candidates, opts = {}) {
|
|
| 862 |
opts shape as runDuel/runRoundRobinSwapped. Tier 5 is a new SCHEDULE on top of a trusted
|
| 863 |
primitive, not a new way of deciding who won a match.
|
| 864 |
|
| 865 |
-
PAIRING RULE (pairRound below): sort the field by current wins (ties keep the
|
| 866 |
-
they arrive in
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 883 |
|
| 884 |
ROUNDS: default `Math.max(3, Math.ceil(Math.log2(candidates.length)))` β the textbook rule
|
| 885 |
of thumb for how many Swiss rounds separate a field of that size β override with `--rounds`.
|
|
@@ -890,12 +905,63 @@ export function runRoundRobinSwapped(candidates, opts = {}) {
|
|
| 890 |
|
| 891 |
const pairKey = (a, b) => [a, b].sort().join("::");
|
| 892 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 893 |
// One round's pairings off the current standings, sorted highest-first. `played` is the set of
|
| 894 |
// pairKey()s already run in ANY earlier round of this bracket; `hadBye` is the set of names
|
| 895 |
// already given a bye. Returns { pairs: [[aRow, bRow], ...], byeName } β `pairs` holds the
|
| 896 |
// standings rows (not the candidate objects; the caller looks the candidate up by name), so
|
| 897 |
-
// this function stays pure and easy to test on plain data.
|
| 898 |
-
|
|
|
|
|
|
|
| 899 |
const order = [...standingsRows].sort((a, b) => b.wins - a.wins);
|
| 900 |
let byeName = null;
|
| 901 |
if (order.length % 2 === 1) {
|
|
@@ -904,34 +970,26 @@ function pairRound(standingsRows, played, hadBye) {
|
|
| 904 |
if (idx === -1) idx = order.length - 1; // everyone's already had one β lowest standing again
|
| 905 |
byeName = order.splice(idx, 1)[0].name;
|
| 906 |
}
|
| 907 |
-
const pairs =
|
| 908 |
-
const pool = order;
|
| 909 |
-
while (pool.length) {
|
| 910 |
-
const a = pool.shift();
|
| 911 |
-
let idx = pool.findIndex(b => !played.has(pairKey(a.name, b.name)));
|
| 912 |
-
if (idx === -1) idx = 0; // every remaining opponent is a repeat β forced rematch, closest standing
|
| 913 |
-
const b = pool.splice(idx, 1)[0];
|
| 914 |
-
pairs.push([a, b]);
|
| 915 |
-
}
|
| 916 |
return { pairs, byeName };
|
| 917 |
}
|
| 918 |
|
| 919 |
// One difficulty bracket's whole Swiss schedule: `numRounds` rounds of pairRound, each pairing
|
| 920 |
// run through runSwappedDuel exactly like round-robin's own pairs β same opts shape, same
|
| 921 |
// fairness. Standings accumulate match wins/losses/draws round over round, same tallying
|
| 922 |
-
// runRoundRobinSwapped already does per pairing
|
|
|
|
| 923 |
function runSwissBracket(candidates, numRounds, opts) {
|
| 924 |
const worlds = opts.worlds || ["korrath", "ferros", "vesper", "kybernet"];
|
| 925 |
const seeds = opts.seeds ?? 2;
|
| 926 |
-
const
|
| 927 |
const standings = new Map(candidates.map(c => [c.name, { name: c.name, wins: 0, losses: 0, draws: 0, byes: 0 }]));
|
| 928 |
const played = new Set(), hadBye = new Set();
|
| 929 |
const rounds = [];
|
| 930 |
for (let round = 1; round <= numRounds; round++) {
|
| 931 |
const { pairs, byeName } = pairRound([...standings.values()], played, hadBye);
|
| 932 |
if (byeName) {
|
| 933 |
-
|
| 934 |
-
row.wins += byePoints; row.byes += 1;
|
| 935 |
hadBye.add(byeName);
|
| 936 |
}
|
| 937 |
const matches = [];
|
|
@@ -949,10 +1007,13 @@ function runSwissBracket(candidates, numRounds, opts) {
|
|
| 949 |
B.wins += res.bWins; B.losses += res.aWins; B.draws += res.draws;
|
| 950 |
matches.push(res);
|
| 951 |
}
|
| 952 |
-
rounds.push({ round, byeName,
|
| 953 |
}
|
| 954 |
const standingsList = [...standings.values()].sort((x, y) => y.wins - x.wins || x.losses - y.losses);
|
| 955 |
-
return {
|
|
|
|
|
|
|
|
|
|
| 956 |
}
|
| 957 |
|
| 958 |
// Swiss-paired tournament over `candidates`: `Math.max(3, Math.ceil(Math.log2(n)))` rounds by
|
|
@@ -1411,9 +1472,9 @@ const CMDS = {
|
|
| 1411 |
const brackets = runSwissTournament(candidates, swissOpts);
|
| 1412 |
for (const { difficulty, rounds, roundsLog, standings } of brackets) {
|
| 1413 |
console.log(`\n=== SWISS -- difficulty: ${difficulty} -- ${rounds} round(s), ${candidates.length} candidates ===`);
|
| 1414 |
-
for (const { round, byeName,
|
| 1415 |
console.log(`\n-- round ${round} --`);
|
| 1416 |
-
if (byeName) console.log(` bye: ${byeName} (
|
| 1417 |
for (const res of matches) { printSwappedDuel(res); console.log(); }
|
| 1418 |
}
|
| 1419 |
console.log(`SWISS STANDINGS (${difficulty})`);
|
|
@@ -1473,9 +1534,10 @@ const USAGE = `AI LAB β a headless bench for the Odyssey opponent.
|
|
| 1473 |
pairing 'duel --candidates' uses, scheduled as SWISS rounds instead
|
| 1474 |
of full round-robin, for candidate pools too large for C(n,2)
|
| 1475 |
pairings to stay cheap. Default rounds: max(3, ceil(log2(n))). Each
|
| 1476 |
-
round pairs candidates by closest current standing
|
| 1477 |
-
repeat pairing
|
| 1478 |
-
-- see the TIER 5 header comment for
|
|
|
|
| 1479 |
|
| 1480 |
Common flags
|
| 1481 |
--overrides f.json inject candidate rows into the AI tables before running:
|
|
|
|
| 841 |
A.wins += res.aWins; A.losses += res.bWins; A.draws += res.draws;
|
| 842 |
B.wins += res.bWins; B.losses += res.aWins; B.draws += res.draws;
|
| 843 |
}
|
| 844 |
+
return {
|
| 845 |
+
difficulty: pinnedDuelDials(diff).difficulty, // normalized β see pinnedDuelDials' own comment
|
| 846 |
+
pairs, standings: [...standings.values()].sort((x, y) => y.wins - x.wins),
|
| 847 |
+
};
|
| 848 |
});
|
| 849 |
}
|
| 850 |
|
|
|
|
| 865 |
opts shape as runDuel/runRoundRobinSwapped. Tier 5 is a new SCHEDULE on top of a trusted
|
| 866 |
primitive, not a new way of deciding who won a match.
|
| 867 |
|
| 868 |
+
PAIRING RULE (findMatching/pairRound below): sort the field by current wins (ties keep the
|
| 869 |
+
stable order they arrive in), then find a matching that avoids every already-played pair IF
|
| 870 |
+
ONE EXISTS. The first cut of this shipped as pure greedy β walk the sorted list, pair each
|
| 871 |
+
candidate with the first not-yet-played opponent remaining β and independent review caught
|
| 872 |
+
that it does NOT deliver the "avoids a repeat unless truly forced" guarantee it claimed:
|
| 873 |
+
fuzz-testing against a brute-force ground-truth checker measured avoidable repeats in roughly
|
| 874 |
+
a quarter to half of realistic tournaments (n=6..50), because a greedy walk can strand two
|
| 875 |
+
candidates with each other even when a full zero-repeat matching exists elsewhere in the same
|
| 876 |
+
round (classic "first fit with no lookahead" failure). findMatching now backtracks: for each
|
| 877 |
+
candidate (closest-standing first) it tries an unplayed opponent before ever trying a repeat,
|
| 878 |
+
and if that choice can't be extended into a complete matching for the rest of the pool, it
|
| 879 |
+
backtracks and tries the next one. Bounded by MATCH_ATTEMPT_CAP (small Swiss pools β this
|
| 880 |
+
tool's whole use case β resolve in a handful of attempts; the cap exists so a pathological
|
| 881 |
+
input can never hang) with a plain greedy pass as the last-resort fallback if the cap is hit,
|
| 882 |
+
so this always terminates and always returns a complete pairing.
|
| 883 |
+
|
| 884 |
+
BYES: odd `candidates.length` leaves one candidate unpaired each round. It's awarded to the
|
| 885 |
+
LOWEST-standing candidate who hasn't had one yet, falling back to lowest-standing overall once
|
| 886 |
+
everyone has β standard Swiss practice. That fallback CAN eventually reach the field's current
|
| 887 |
+
leader in a long-enough Swiss on a small pool (this tool's own default rounds formula keeps
|
| 888 |
+
real runs far short of that, but nothing prevents it if `--rounds` is pushed high). A bye is
|
| 889 |
+
NOT a win: it's the schedule's own bookkeeping for "this candidate wasn't paired," and the
|
| 890 |
+
first cut of this got that wrong too β it credited a bye as a full pairing's worth of wins
|
| 891 |
+
(`worlds.length * seeds * 2`, i.e. a maximum-margin sweep) with zero matching losses, which an
|
| 892 |
+
independent review caught letting a candidate that never fights at all tie or outrank one that
|
| 893 |
+
won a genuine match (reproduced live: a neverInitiates/zero-standing-army candidate that drew
|
| 894 |
+
a bye matched a real winner's standing). A bye now adds to nobody's `wins`/`losses` β only its
|
| 895 |
+
own `byes` counter β so standings reflect actual fighting, never schedule luck. `byeMatchCount`
|
| 896 |
+
is kept as an informational field only (how big a real pairing this round would have been),
|
| 897 |
+
never applied to anyone's tally.
|
| 898 |
|
| 899 |
ROUNDS: default `Math.max(3, Math.ceil(Math.log2(candidates.length)))` β the textbook rule
|
| 900 |
of thumb for how many Swiss rounds separate a field of that size β override with `--rounds`.
|
|
|
|
| 905 |
|
| 906 |
const pairKey = (a, b) => [a, b].sort().join("::");
|
| 907 |
|
| 908 |
+
// Total (candidate, opponent) trial attempts findMatching will spend across an ENTIRE call
|
| 909 |
+
// (shared across all its recursive branches via the mutable `budget` object) before giving up
|
| 910 |
+
// and falling back to a plain greedy pass. Small Swiss pools β this tool's whole use case β
|
| 911 |
+
// resolve in well under this; it exists purely so a pathological input can never hang.
|
| 912 |
+
const MATCH_ATTEMPT_CAP = 20000;
|
| 913 |
+
|
| 914 |
+
// Backtracking search for a COMPLETE ZERO-REPEAT matching over `pool` (already sorted by
|
| 915 |
+
// standing, closest-first) β every pair it considers must be absent from `played`; it never
|
| 916 |
+
// accepts a repeat itself. For the pool's first member, tries each unplayed opponent in
|
| 917 |
+
// closest-standing order, recursing on what's left; if a choice can't be extended into a
|
| 918 |
+
// complete zero-repeat matching for the REST of the pool, it backtracks and tries the next
|
| 919 |
+
// opponent β the step a plain greedy walk skips, which is exactly how it can strand two
|
| 920 |
+
// candidates with each other (see the header comment above). Returns null β not a
|
| 921 |
+
// repeat-containing matching β when no zero-repeat completion exists from here, so a caller
|
| 922 |
+
// can tell "impossible" apart from "found one" and only then fall back to greedyMatching, which
|
| 923 |
+
// is the one place a repeat is ever chosen. `budget.n` is decremented on every attempt and
|
| 924 |
+
// shared across the whole recursion (mutated in place), so total work is bounded regardless of
|
| 925 |
+
// tree depth; running out returns null the same as genuine impossibility, and the caller's
|
| 926 |
+
// greedy fallback still always completes since repeats are unconditionally allowed there.
|
| 927 |
+
function findMatching(pool, played, budget) {
|
| 928 |
+
if (!pool.length) return [];
|
| 929 |
+
const [a, ...rest] = pool;
|
| 930 |
+
for (const b of rest) {
|
| 931 |
+
if (played.has(pairKey(a.name, b.name))) continue; // this search only ever considers UNPLAYED pairs
|
| 932 |
+
if (budget.n <= 0) return null;
|
| 933 |
+
budget.n--;
|
| 934 |
+
const remaining = rest.filter(c => c !== b);
|
| 935 |
+
const sub = findMatching(remaining, played, budget);
|
| 936 |
+
if (sub) return [[a, b], ...sub];
|
| 937 |
+
}
|
| 938 |
+
return null; // no zero-repeat completion from here (or the budget ran out) β caller falls back
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
// The original greedy pass (closest-standing first, first not-yet-played opponent, forced repeat
|
| 942 |
+
// if none remain) β kept only as findMatching's fallback if MATCH_ATTEMPT_CAP is ever exhausted,
|
| 943 |
+
// so pairRound always terminates and always returns a complete pairing either way.
|
| 944 |
+
function greedyMatching(pool, played) {
|
| 945 |
+
const rest = [...pool];
|
| 946 |
+
const pairs = [];
|
| 947 |
+
while (rest.length) {
|
| 948 |
+
const a = rest.shift();
|
| 949 |
+
let idx = rest.findIndex(b => !played.has(pairKey(a.name, b.name)));
|
| 950 |
+
if (idx === -1) idx = 0;
|
| 951 |
+
const b = rest.splice(idx, 1)[0];
|
| 952 |
+
pairs.push([a, b]);
|
| 953 |
+
}
|
| 954 |
+
return pairs;
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
// One round's pairings off the current standings, sorted highest-first. `played` is the set of
|
| 958 |
// pairKey()s already run in ANY earlier round of this bracket; `hadBye` is the set of names
|
| 959 |
// already given a bye. Returns { pairs: [[aRow, bRow], ...], byeName } β `pairs` holds the
|
| 960 |
// standings rows (not the candidate objects; the caller looks the candidate up by name), so
|
| 961 |
+
// this function stays pure and easy to test on plain data. Exported for direct, fast unit tests
|
| 962 |
+
// against synthetic standings/played-sets β the pairing algorithm's own correctness doesn't
|
| 963 |
+
// depend on running real matches.
|
| 964 |
+
export function pairRound(standingsRows, played, hadBye) {
|
| 965 |
const order = [...standingsRows].sort((a, b) => b.wins - a.wins);
|
| 966 |
let byeName = null;
|
| 967 |
if (order.length % 2 === 1) {
|
|
|
|
| 970 |
if (idx === -1) idx = order.length - 1; // everyone's already had one β lowest standing again
|
| 971 |
byeName = order.splice(idx, 1)[0].name;
|
| 972 |
}
|
| 973 |
+
const pairs = findMatching(order, played, { n: MATCH_ATTEMPT_CAP }) || greedyMatching(order, played);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 974 |
return { pairs, byeName };
|
| 975 |
}
|
| 976 |
|
| 977 |
// One difficulty bracket's whole Swiss schedule: `numRounds` rounds of pairRound, each pairing
|
| 978 |
// run through runSwappedDuel exactly like round-robin's own pairs β same opts shape, same
|
| 979 |
// fairness. Standings accumulate match wins/losses/draws round over round, same tallying
|
| 980 |
+
// runRoundRobinSwapped already does per pairing; a bye adds only to `byes`, never `wins`/
|
| 981 |
+
// `losses` β see the header comment above.
|
| 982 |
function runSwissBracket(candidates, numRounds, opts) {
|
| 983 |
const worlds = opts.worlds || ["korrath", "ferros", "vesper", "kybernet"];
|
| 984 |
const seeds = opts.seeds ?? 2;
|
| 985 |
+
const byeMatchCount = worlds.length * seeds * 2; // informational only β == a real pairing's runSwappedDuel `n`
|
| 986 |
const standings = new Map(candidates.map(c => [c.name, { name: c.name, wins: 0, losses: 0, draws: 0, byes: 0 }]));
|
| 987 |
const played = new Set(), hadBye = new Set();
|
| 988 |
const rounds = [];
|
| 989 |
for (let round = 1; round <= numRounds; round++) {
|
| 990 |
const { pairs, byeName } = pairRound([...standings.values()], played, hadBye);
|
| 991 |
if (byeName) {
|
| 992 |
+
standings.get(byeName).byes += 1; // NOT a win β see header comment
|
|
|
|
| 993 |
hadBye.add(byeName);
|
| 994 |
}
|
| 995 |
const matches = [];
|
|
|
|
| 1007 |
B.wins += res.bWins; B.losses += res.aWins; B.draws += res.draws;
|
| 1008 |
matches.push(res);
|
| 1009 |
}
|
| 1010 |
+
rounds.push({ round, byeName, byeMatchCount: byeName ? byeMatchCount : null, matches });
|
| 1011 |
}
|
| 1012 |
const standingsList = [...standings.values()].sort((x, y) => y.wins - x.wins || x.losses - y.losses);
|
| 1013 |
+
return {
|
| 1014 |
+
difficulty: pinnedDuelDials(opts.difficulty).difficulty, // normalized β see pinnedDuelDials' own comment
|
| 1015 |
+
rounds: numRounds, byeMatchCount, roundsLog: rounds, standings: standingsList,
|
| 1016 |
+
};
|
| 1017 |
}
|
| 1018 |
|
| 1019 |
// Swiss-paired tournament over `candidates`: `Math.max(3, Math.ceil(Math.log2(n)))` rounds by
|
|
|
|
| 1472 |
const brackets = runSwissTournament(candidates, swissOpts);
|
| 1473 |
for (const { difficulty, rounds, roundsLog, standings } of brackets) {
|
| 1474 |
console.log(`\n=== SWISS -- difficulty: ${difficulty} -- ${rounds} round(s), ${candidates.length} candidates ===`);
|
| 1475 |
+
for (const { round, byeName, matches } of roundsLog) {
|
| 1476 |
console.log(`\n-- round ${round} --`);
|
| 1477 |
+
if (byeName) console.log(` bye: ${byeName} (not paired this round -- no win/loss credit either way)`);
|
| 1478 |
for (const res of matches) { printSwappedDuel(res); console.log(); }
|
| 1479 |
}
|
| 1480 |
console.log(`SWISS STANDINGS (${difficulty})`);
|
|
|
|
| 1534 |
pairing 'duel --candidates' uses, scheduled as SWISS rounds instead
|
| 1535 |
of full round-robin, for candidate pools too large for C(n,2)
|
| 1536 |
pairings to stay cheap. Default rounds: max(3, ceil(log2(n))). Each
|
| 1537 |
+
round pairs candidates by closest current standing, backtracking to
|
| 1538 |
+
find a zero-repeat pairing whenever one exists rather than a repeat
|
| 1539 |
+
a greedy walk could've avoided -- see the TIER 5 header comment for
|
| 1540 |
+
the pairing/bye rules.
|
| 1541 |
|
| 1542 |
Common flags
|
| 1543 |
--overrides f.json inject candidate rows into the AI tables before running:
|