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;
}