Claude Claude Sonnet 5 commited on
Commit
e9ad1d0
Β·
unverified Β·
1 Parent(s): 07e72eb

Fix two real bugs in Tier 5 Swiss tournament found by belated review

Browse files

A 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

Files changed (3) hide show
  1. docs/odyssey-ai-review.md +3 -2
  2. test/ailab.test.js +106 -18
  3. tools/ailab.js +103 -41
docs/odyssey-ai-review.md CHANGED
@@ -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 | 1880β†’1893 (13 new tests). Independently verified live: a deliberately lopsided field's strong candidate rose to the top of standings; byes rotated rather than always landing on the same candidate; rematches avoided while a fresh opponent remained | **yes** |
631
- | 2026-08-03 | the difficulty-echo bug found (and left, as non-blocking) during the side-swap review also exists one level up, in `runDuel`'s own aggregate return, not just the per-pairing summaries built on it | `runDuel` now returns `dials.difficulty` (the normalized value every row already used) instead of the raw, unvalidated `difficulty` parameter | Confirmed live: `--difficulty totallyBogusTypo` previously reported `difficulty: "totallyBogusTypo"` at the aggregate level while every row correctly showed `"medium"`; now the aggregate reads `"medium"` too. `runSwappedDuel`/`runDuelBrackets` inherit the fix for free (they read `.difficulty` off `runDuel`'s result). No behavior change for any valid difficulty string | **yes** |
 
 
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** |
test/ailab.test.js CHANGED
@@ -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 worth a real pairing's own match count, not an arbitrary point", () => {
 
 
 
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
- assert.equal(round.byePoints, expected);
780
- if (round.matches.length) assert.equal(round.matches[0].n, expected, "a real pairing's own match count must match the bye's credit");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 [bracket] = runSwissTournament(candidates, shortSwiss({ rounds: 3 }));
786
- const seen = new Set();
787
- for (const { matches } of bracket.roundsLog) {
788
- for (const res of matches) {
789
- const key = [res.aName, res.bName].sort().join("::");
790
- assert.ok(!seen.has(key), `pairing ${key} repeated across rounds even though unplayed opponents remained (6 candidates, 3 rounds)`);
791
- seen.add(key);
 
 
 
792
  }
793
  }
794
  });
795
 
796
- test("Swiss standings: total wins minus total losses equals the bye credit handed out, nothing else", () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) β€” a bye is the only source of a win with no matching loss.
 
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
- const byeCredit = bracket.roundsLog.filter(r => r.byeName).length * bracket.byePoints;
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 = [
tools/ailab.js CHANGED
@@ -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 { difficulty: diff, pairs, standings: [...standings.values()].sort((x, y) => y.wins - x.wins) };
 
 
 
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 stable order
866
- they arrive in β€” i.e. the standings Map's own insertion order, which starts as the input
867
- candidate order), then walk down the sorted list pairing each candidate with the
868
- highest-standing opponent it hasn't already played. That's the simplest version of the
869
- standard Swiss "fold down on repeat" rule β€” deliberately not a full optimal-matching solver
870
- (this bench already prefers "the simplest thing that works" β€” see the SEARCH header
871
- comment above): a forced rematch only happens when every remaining opponent has already
872
- been played, which small pools with many rounds can hit and large pools essentially never
873
- do.
874
-
875
- BYES: odd `candidates.length` leaves one player unpaired each round. It's awarded to the
876
- LOWEST-standing candidate who hasn't had one yet (falling back to lowest-standing overall
877
- once everyone has) β€” standard Swiss practice, and it keeps a bye from ever being handed to
878
- the field's current leader. A bye counts as a full pairing's worth of wins (`byePoints`,
879
- computed the same way a real pairing's match count would be: `worlds.length * seeds * 2`,
880
- the exact `n` runSwappedDuel produces for that opts shape) β€” anything smaller would make a
881
- bye worth less than a real result and distort the standings for the specific candidate the
882
- schedule couldn't pair that round, which is not a judgement about their play.
 
 
 
 
 
 
 
 
 
 
 
 
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
- function pairRound(standingsRows, played, hadBye) {
 
 
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 byePoints = worlds.length * seeds * 2; // == a real pairing's runSwappedDuel `n` β€” see header comment
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
- const row = standings.get(byeName);
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, byePoints: byeName ? byePoints : null, matches });
953
  }
954
  const standingsList = [...standings.values()].sort((x, y) => y.wins - x.wins || x.losses - y.losses);
955
- return { difficulty: opts.difficulty, rounds: numRounds, byePoints, roundsLog: rounds, standings: standingsList };
 
 
 
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, byePoints, matches } of roundsLog) {
1415
  console.log(`\n-- round ${round} --`);
1416
- if (byeName) console.log(` bye: ${byeName} (+${byePoints}, a real pairing's worth of match wins)`);
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 and avoids a
1477
- repeat pairing unless every other opponent has already been played
1478
- -- see the TIER 5 header comment for the pairing/bye rules.
 
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: