Spaces:
Paused
Paused
File size: 8,916 Bytes
9af0ddd 925767d 9af0ddd 925767d 9af0ddd 87a9642 9af0ddd 87a9642 9af0ddd 87a9642 9af0ddd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | // @ts-check
"use strict";
/* ============================================================
ELO β one rating implementation, shared by the in-game competition screen AND tools/ailab.js's
duel/swiss CLI output (docs/competitions-and-elo.md D1: "if the bench and the game each grow
their own rating math, a number the player sees in-game and a number a tuning session reports
stop meaning the same thing"). Root-level, not under engine/ β a rating is not sim state, it's
a fact about a series of matches the caller already resolved some other way (see D1).
Pure rating math over data the CALLER owns: no clock, no storage, no DOM, no Math.random. A
`ratings` table is a plain object keyed by entrant name β the caller decides what "entrant name"
means for a given pool (D2: ratings are bracketed by difficulty and never blended, so a caller
that wants "Aggressive @ Hard" and "Aggressive @ Medium" kept apart uses two separate `ratings`
objects, one per bracket β this module has no notion of "bracket" itself).
D7 is the whole formula set implemented here: plain Elo (E = 1/(1+10^((Rb-Ra)/400)),
R' = R + K(S-E)), starting rating 1200, K = 40 while provisional (<10 games) then 20 then 10
above 2400, no margin-of-victory scaling (a match's score margin is a separate, non-rating
field elsewhere β see tools/duelCore.js's row shape β never folded in here).
============================================================ */
/**
* @typedef {{ rating: number, games: number }} RatingEntry
* One entrant's rating and how many rated games it has played so far, in whichever pool the
* caller's `ratings` object represents (D2: typically one (entrant, difficulty bracket) pair).
*/
/** @typedef {Object.<string, RatingEntry>} RatingsTable Plain object, keyed by entrant name. */
/**
* @typedef {{ aName: string, bName: string, score: number }} MatchRow
* One match's result for applySeries, `score` from A's point of view: 1 (A won), 0.5 (draw),
* 0 (A lost) β the same three values applyResult's own `score` parameter takes.
*/
// Every new entrant starts here (D7). Exported so a caller never hardcodes the number twice.
export const INITIAL_RATING = 1200;
// Games played before an entrant "graduates" out of the wide provisional K (D7) β the same
// threshold competitionLedger.js's standings read-back uses to flag a row `provisional`, so the
// number lives in exactly one place (this file's own INITIAL_RATING precedent) rather than being
// hardcoded a second time wherever "is this rating still noisy" needs asking.
export const PROVISIONAL_GAMES = 10;
/**
* The standard Elo expectation: A's probability of "winning" this pairing (1 = certain win,
* 0.5 = a coin flip, 0 = certain loss), purely a function of the ratings gap β D7's formula,
* verbatim. Symmetric by construction: expectedScore(r, r) is always exactly 0.5, and
* expectedScore(a, b) + expectedScore(b, a) is 1 to within floating-point noise (see
* test/elo.test.js β two independently-rounded `10 ** x` evaluations aren't bit-exact
* complements of one another, which is why applyResult below derives B's expectation as
* `1 - expectedScore(a, b)` rather than by calling this a second time with the arguments
* swapped).
* @param {number} ratingA
* @param {number} ratingB
* @returns {number} A's expected score, in [0, 1].
*/
export function expectedScore(ratingA, ratingB) {
return 1 / (1 + 10 ** ((ratingB - ratingA) / 400));
}
/**
* How much one match can move a rating, decided by the PLAYER'S OWN games/rating going into that
* match (D7). Provisional (fewer than 10 games played so far) always uses the wide K, even for a
* seeded-high rating β a brand-new entrant is provisional however it started, so this check comes
* before the rating-based tier. Only once there's a track record does the 2400 rating tier apply.
* @param {number} games games this player had played BEFORE this match (i.e. going INTO it).
* @param {number} rating this player's rating going INTO this match.
* @returns {number} the K-factor: 40, 20, or 10.
*/
export function kFactor(games, rating) {
if (games < PROVISIONAL_GAMES) return 40;
return rating >= 2400 ? 10 : 20;
}
/**
* Apply one match's result to `ratings`, MUTATING AND RETURNING it (this module never clones β
* a caller that needs the pre-match table preserved must copy it first, e.g.
* `structuredClone(ratings)`). Either side's entry is created at
* `{ rating: INITIAL_RATING, games: 0 }` the first time that name is seen, so a caller never has
* to pre-seed every entrant before a competition can run.
*
* K for each side comes from THAT side's own games/rating going into this call (kFactor above) β
* so a provisional A meeting an established B is ordinary, correct Elo (each side's rating moves
* by its own K), not a bug. The two deltas are exact negatives of each other ONLY when both sides
* land in the same K-bucket (equal games-bucket and rating-bucket) β see test/elo.test.js for both
* the equal-K and unequal-K cases. This is guaranteed by construction: both deltas are derived from
* ONE shared `diff` (A's actual-minus-expected score this match), not from two independently
* rounded `expectedScore` calls, so at equal K the negation is exact, not merely close.
*
* REJECTS aName === bName, before touching `ratings` at all. RatingsTable is keyed by name (see
* the module header), so a same-name pairing would make `a`/`b` above alias the SAME object:
* this function would compute A's post-match entry, write it to `ratings[aName]`, then
* unconditionally overwrite that exact slot with B's post-match entry (`ratings[bName] = ...` runs
* second and always wins, since aName and bName are one key) β A's half silently vanishes and the
* survivor represents neither side faithfully. Two entrants that are conceptually different (say,
* different strategies) but happen to share one display string collide exactly the same way, since
* this module has no notion of identity beyond the name string. That's not a match anyone can
* actually referee β an entrant can't play itself β so it's refused up front rather than silently
* producing a garbage rating; every shipped caller (competition.js's buildJob, tools/ailab.js's
* runDuel) already rejects this earlier, with a user/CLI-facing message, but this check is the
* shared backstop any caller gets for free, present or future, per D1's "one implementation" point.
* @param {RatingsTable} ratings
* @param {string} aName
* @param {string} bName
* @param {number} score A's result: 1 (A won), 0.5 (draw), 0 (A lost).
* @returns {RatingsTable} the same `ratings` object, mutated in place.
* @throws {Error} if aName === bName.
*/
export function applyResult(ratings, aName, bName, score) {
if (aName === bName) {
throw new Error(
`applyResult: A and B must be different entrants, both were "${aName}" β RatingsTable is ` +
`keyed by name, so a same-name pairing would collide both sides into one entry instead of two`
);
}
const a = ratings[aName] || (ratings[aName] = { rating: INITIAL_RATING, games: 0 });
const b = ratings[bName] || (ratings[bName] = { rating: INITIAL_RATING, games: 0 });
const kA = kFactor(a.games, a.rating);
const kB = kFactor(b.games, b.rating);
// One match, one shared "surprise" (actual minus expected) from A's side; B's is its exact
// negation by construction (a single game has exactly two complementary outcomes) β see the
// header comment above for why this beats computing B's own expectedScore(b, a) independently.
const diff = score - expectedScore(a.rating, b.rating);
const ratingA = a.rating + kA * diff;
const ratingB = b.rating - kB * diff;
ratings[aName] = { rating: ratingA, games: a.games + 1 };
ratings[bName] = { rating: ratingB, games: b.games + 1 };
return ratings;
}
/**
* Fold a series of match rows through applyResult, ONE AT A TIME, in the array's OWN order.
* Deliberately does NOT sort or reorder `rows` itself β Elo is order-dependent (each match reads
* whatever rating the previous ones left behind), so a fixed rows array always replays to the same
* final table (reproducibility), but the SAME matches in a DIFFERENT order settle on a DIFFERENT
* one (see test/elo.test.js's ORDER MATTERS case). Establishing the canonical order β round, then
* pairing index, then side (D6) β is the CALLER's responsibility; this is the seam where that
* discipline is actually enforced, by staying silent about ordering rather than imposing one of
* its own.
* @param {RatingsTable} ratings
* @param {MatchRow[]} rows
* @returns {RatingsTable} the same `ratings` object, mutated in place.
*/
export function applySeries(ratings, rows) {
for (const { aName, bName, score } of rows) applyResult(ratings, aName, bName, score);
return ratings;
}
|