Skip to content

feat: Elo rating system (PlayerRatings elo() semantics) - #297

Closed
seonghobae wants to merge 5 commits into
seonghobae-top1from
seonghobae-elo
Closed

feat: Elo rating system (PlayerRatings elo() semantics)#297
seonghobae wants to merge 5 commits into
seonghobae-top1from
seonghobae-elo

Conversation

@seonghobae

@seonghobae seonghobae commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Iteration 58 of the autonomous paper-implementation loop: Elo rating system (Elo, 1978), implemented from the READ CRAN PlayerRatings 1.1-0 sources (R/ratings.R elo() + src/ratings.c elo_c).

Scope

  • Rust core mlsirm_core::scaling::elo_rating: batch-per-period Elo updates (all expectations within a period use period-start ratings), per-game white advantage gamma, PlayerRatings W/D/L bookkeeping (only scores exactly 1/0.5/0) and lag (periods since last appearance, reset for current-period players AFTER the played-before increment — first-timers end at 0).
  • Unsorted period labels grouped by ascending value (matches R split() factor-level ordering) — mandated by spec-verify for fidelity.
  • Documented divergences: self-play rejected; scalar K factor only (function kfac, status carry-in, history out of scope); index-based players (never-appearing players keep games=0, lag=0).
  • PyO3 binding + Python wrapper fast_mlsirm.elo_rating(games, n_players, init=2200, kfac=27, gamma=None) with (g, 4) schedule and scalar-gamma broadcast; PlayerRatings defaults.

Evidence discipline

  • Sources: PlayerRatings R/ratings.R + src/ratings.c READ in full (implementation source of record). Elo (1978) NOT READ — cited as origin per PlayerRatings documentation. Citation-governance header in scaling.rs.
  • Executed exact-rational oracle (independent implementation, Fractions): EA single-period exact pins (r = [22032/11, 1984, 22144/11] with a gamma=400 game), EB two-period batch-semantics proof at kfac=400 (r = [24600/11, 19400/11, 2000], lag [0,0,1]), EC 4-player float regression, ED closed-form nonzero-gamma pin.
  • Adversarial spec-verify BEFORE implementation: APPROVED-WITH-CHANGES; all 6 mandatory changes adopted (sort-don't-reject unsorted periods; EC draw/loss pin swap fixed; MU4 expected-lag corrected to [2,2,2]; oracle ED comment fixed; MC conservation extended to nonzero gamma; edge fixtures kfac=0 / unsorted / fractional score / saturation added).
  • PROVED identity: E_w + E_b = 1 for any finite gamma (exponents are exact negations) — so sum(ratings) = n*init is conserved for ANY gamma, and an E_b = 1 - E_w refactor is behaviorally unobservable (documented unkillable mutant; discriminating anchors target gamma sign/drop instead).
  • 5 mutation kills EXECUTED (baseline restored green after each): MU1 sequential-update (killed by EA/EB/EC), MU2 black actual-score flip (6 tests), MU3 gamma sign flip (EA/ED/saturation), MU4 lag-reset drop (EA/EB/EC/kfac0), MU5 divisor 400-800 (EA/EB/EC/ED).
  • Suites: cargo 778 passed / 0 failed (incl. MC-500 run explicitly); pytest 294 passed. Every test assert reads crate/wrapper outputs.

Stacked on #296 (Kendall circular triads + u).

Adversarial impl-review outcome

Three review rounds were run against this PR:

  • Round 1 ? FINDINGS (1 High): the Python wrapper coerced games to float64 before casting periods to uint64, so distinct integer period labels above 2^53 silently merged. Fixed in 912f8d8: integer-dtype 2-D inputs now take the period column losslessly via raw[:, 0].astype(np.uint64); the float path rejects periods >= 2**53. Regression test_large_period_labels_exact pins two-period crate ratings for labels 2^53 / 2^53+1.
  • Round 2 ? FINDINGS (1 High): float32 inputs lose integer fidelity above 2^24 before float64 promotion, passing the 2^53 guard. Fixed in 31bd259: the float-path bound is now dtype-derived (2.0**np.finfo(raw.dtype).nmant). Regression test_float32_period_labels_rejected.
  • Round 3 ? CLEAN: no remaining silent-merge path for distinct period labels in numeric ndarray inputs. One non-blocking nit noted: the dtype-derived bound 2**nmant is over-strict by a factor of 2 (true exact-integer ceiling is 2**(nmant+1)); this only rejects more inputs, never merges.

TestElo: 7/7 pass.

seonghobae and others added 2 commits July 27, 2026 02:02
Implements the Kendall & Babington Smith (1940) circular-triad
consistency test and coefficient of agreement u, as implemented by the
eba R package 1.10-0 (circular.R / kendall.u.R, source READ; the 1940
paper and Alway's exact tables NOT READ, cited as origins per eba's
manual pages).

- Rust core scaling::circular_triads: T = C(n,3) - sum_j C(d_j,2)
  (integer arithmetic), T_max, T_exp = C(n,3)/4, zeta = 1 - T/T_max;
  EXACT null p-values for n <= 10 from embedded distributions (dyadic
  rationals, assert_eq!-pinned), continuity-corrected chi-square for
  n >= 11 (df = n(n-1)(n-2)/(n-4)^2). Documented divergences from eba:
  n = 2 and malformed/incomplete tournaments are rejected.
- Rust core scaling::kendall_u: Sigma, u = 2*Sigma/(C(m,2)*C(n,2)) - 1,
  min_u, RAW chi-square (can be negative under continuity correction;
  only the p-value clamps), df = C(n,2)m(m-1)/(m-2)^2. Stricter than
  eba: every pair must have the same m >= 3 judges.
- PyO3 bindings + Python wrappers circular_triads / kendall_u with
  CircularTriadsResult / KendallUResult dataclasses; input validation
  before casts.
- 11 Rust tests (exact-Fraction oracle pins: 1940 dog example, n = 12
  chi-square path vs scipy, table integrity sum = 2^C(n,2), negative
  raw chi2, error contracts, MC-500 invariants #[ignore]); 8 Python
  tests. Five mutants (drop pairing, T_max parity swap, drop
  opposite-tail, corr sign flip, drop Sigma correction) all EXECUTED
  and killed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Implements the Elo (1978) rating system as specified by the CRAN
PlayerRatings 1.1-0 package's elo() (R/ratings.R + elo_c C kernel, both
READ): batch-per-period updates where every expected score within a
rating period uses the period-start ratings, per-game white advantage
gamma, and PlayerRatings win/draw/loss and lag bookkeeping. Elo's 1978
book was NOT read and is cited as the origin per PlayerRatings.

- Rust core mlsirm_core::scaling::elo_rating (EloResult with ratings,
  games, wins, draws, losses, lag); periods may be unsorted (grouped by
  ascending label, matching R split() ordering); self-play rejected and
  scalar K factor only (documented divergences).
- PROVED: E_w + E_b = 1 identically for any finite gamma (the exponents
  are exact negations), so rating sums are conserved at n*init and an
  E_b = 1 - E_w refactor is a documented unkillable mutant.
- Tests anchored to an executed exact-rational oracle: exact-fraction
  single/two-period fixtures (batch-semantics proof at kfac=400), float
  regression, closed-form nonzero-gamma pin, kfac=0, unsorted periods,
  fractional-score bookkeeping, saturation, error contract, and an
  MC-500 invariant suite (ignored by default). Five mutation kills
  executed: sequential-update, black-score flip, gamma sign flip, lag
  reset drop, logistic divisor.
- PyO3 binding elo_rating; Python wrapper fast_mlsirm.elo_rating with
  (g, 4) [period, white, black, score] schedule, scalar gamma broadcast,
  and PlayerRatings defaults init=2200, kfac=27.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a32acc5b-9f99-46d1-b890-636e5b5db61d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch seonghobae-elo

Comment @coderabbitai help to get the list of available commands.

seonghobae and others added 2 commits July 27, 2026 02:51
Impl-review finding (High): the wrapper coerced the whole games array to
float before the uint64 period cast, so distinct integer period labels
above 2**53 silently merged into one rating period (wrong batching, wrong
ratings), and out-of-u64 labels were accepted with only a NumPy warning.

Fix: take period labels losslessly from integer-dtype input arrays, and
reject float-path labels >= 2**53 (float(2**53+1) already rounds to
2**53, so that value is ambiguous). Regression test pins the crate's
sequential-update ratings for labels 2**53 / 2**53+1 passed as uint64 and
asserts ValueError on the float path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…integer bound

Round-2 review finding (High): np.float32 games arrays lose integer
fidelity above 2**24 (float16 above 2**11) before the float64 promotion,
so distinct period labels could still silently merge under the previous
2**53-only guard. The float-path bound is now derived from the input
dtype's mantissa (np.finfo(dtype).nmant). Regression test pins ValueError
for float32 labels at 2**24 and exact crate ratings below the bound.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Base automatically changed from seonghobae-kendall to seonghobae-top1 July 31, 2026 12:37
* feat(scaling): Glicko rating system with deviation inflation

Implements the Glicko rating system as a Rust core
(mlsirm_core::scaling::glicko_rating) with a thin PyO3 binding and Python
wrapper. Sources READ: Glickman's 'The Glicko system' technical note
(worked example reproduced to full float64 precision) and CRAN
PlayerRatings 1.1-0 glicko()/glicko_c. Glickman (1999), the derivation
paper, was NOT read and is cited as the origin per both READ sources.

- Batch-per-period Step 2 updates with opponent-g weighting and the
  new-variance rating step; participant-only Step 1b inflation
  RD = min(sqrt(RD^2 + (lag+1) c^2), rdmax).
- Per-player init_rating/init_dev arrays (heterogeneous RDs); results
  cover ALL 0..n players (documented no-status divergence from R).
- Documented non-identity: no rating-sum conservation (pinned by test).
- Tests anchored to an executed float64 oracle: Glickman worked-example
  anchor, two-period inflation/lag/idle-player full-vector pins,
  rdmax clamp, gamma exact pins, unsorted periods, fractional score,
  error contract, MC-500 (#[ignore]).
- Seven executed mutation kills: opponent-g swap, inflation off-by-one,
  clamp drop, stale-variance update, missing q^2, gamma sign,
  all-player inflation.
- Python wrapper inherits the Elo period-label fidelity contract
  (integer-dtype lossless u64 path; dtype-derived float bound).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(scaling): float period-fidelity bound off by one mantissa bit

np.finfo(dtype).nmant excludes the implicit leading bit, so the
exact-integer ceiling of a float dtype is 2**(nmant + 1), not 2**nmant.
The elo/glicko wrappers were rejecting exactly representable period
labels one power of two early (float32 at 2**23, float64 at 2**52).
Bound is now 2**(nmant + 1) with the >= comparison kept (2**53 itself
is ambiguous because 2**53 + 1 rounds onto it). Boundary tests pin
acceptance of 2**24 - 1 (float32) and 2**53 - 1 (float64) via crate
game tallies, killing a 2**nmant mutant.

Found by adversarial implementation review of PR #298.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@seonghobae

Copy link
Copy Markdown
Contributor Author

Superseded by #374 which lands the remaining #290#328 stack tip (seonghobae-ncohen feature set) onto main after #290 squash-merge made intermediate retargets CONFLICTING. Content preserved in #374 merge.

@seonghobae seonghobae closed this Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant