Skip to content

feat(scaling): multiplayer Elo rating (PlayerRatings elom) - #303

Closed
seonghobae wants to merge 5 commits into
seonghobae-glicko2from
seonghobae-elom
Closed

feat(scaling): multiplayer Elo rating (PlayerRatings elom)#303
seonghobae wants to merge 5 commits into
seonghobae-glicko2from
seonghobae-elom

Conversation

@seonghobae

@seonghobae seonghobae commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Multiplayer Elo rating (PlayerRatings elom())

Iteration 62 of the paper-implementation loop. Stacked on #302 (base seonghobae-stephenson).

What

elom_rating: nn-seat multiplayer Elo events with rank base scores. Per period each player receives ONE update K*(ascore - escore) where ascore sums the seat base score and escore sums (r_p - event mean rating)/40 over the player's events. K is a constant or the kriichi experience decay max(kv, 1 - (1-kv)*games/gv) using PRE-period cumulative games. placing=True ranks by lowest score. Bookkeeping: games, places (n x nn finish counts), lag.

Sources (citation governance)

  • CRAN PlayerRatings 1.1-0 R/ratings.R lines 739-932 (elom driver) — READ, normative
  • src/ratings.c lines 45-80 (elom_c kernel) — READ, normative
  • R/ratings.R lines 1006-1020 (kriichi) — READ, normative
  • No journal paper exists for this system (CRAN provenance only) — stated in code headers.

Faithfully reproduces the R quirk that partial events shrink the ORIGINAL base exactly once regardless of empty-seat count (sbase <- basev resets inside the shrink loop, R:855-866) — my first oracle draft applied the shrink cumulatively and was corrected against the verbatim R source.

Spec-verify (adversarial, BEFORE implementation)

Verdict REDUCED-SCOPE with 12 findings, all adopted (spec Rev-2), including the BLOCKER on missing-seat semantics (adopted contract: player == -1 iff score NaN, jointly enforced), nn=2 acceptance, MC tolerance semantics, and a new anchor E9 forcing an executed kill for the per-event-update mutant.

Tests (every assert reads crate outputs)

  • 9 exact-value anchors E1-E9 (dyadic rationals; hand-derived exact-Fraction oracle EXECUTED all-pass; E5 uses a bit-identical f64 expression with hex pins)
  • Error contract incl. nn=2 acceptance; MC-500 permutation invariance #[ignore] (EXECUTED, passes)
  • 6/6 mutation kills EXECUTED: MU1 /40-drop -> E2, MU2 event-mean-over-all-n -> E3, MU3 cumulative-shrink -> E8, MU4 post-period kriichi games -> E2, MU5 tie-rank >= -> E4, MU6 per-event-update -> E9. Baseline restored and re-verified.

Suites

  • cargo test -p mlsirm-core --lib: 812 passed (10 new)
  • pytest tests/test_paper_features.py: 313 passed (3 new)
  • PyO3 binding smoke through the rebuilt pyd: E1 exact.

Adversarial impl-review outcome

Round 1: FINDINGS ? 1 Medium: the Python wrapper cast unsigned player arrays directly to int64, so np.uint64.max wrapped to -1 and was silently treated as the empty-seat sentinel. Fixed in 0a47ead (guard rejecting unsigned ids above i64::MAX + regression test). The reviewer independently recomputed anchors E2/E8/E9 from the R/C source and matched all pins.

Round 2: CLEAN ? fix verified (reproduction now raises ValueError; uint32/valid-uint64/int64-sentinel probes pass; no new findings).

seonghobae and others added 3 commits July 27, 2026 05:12
Rust core stephenson_rating extending Glicko with per-game neighborhood
variance (ngames*hval^2), per-game bonus bval/100 on both sides,
participants-only lambda drift toward opponents, and (lag+1)*cval^2
per-period deviation-variance inflation clamped at rdmax^2. Normative
source: CRAN PlayerRatings 1.1-0 R driver (ratings.R 591-737) + C kernel
(ratings.c stephenson_c 157-202), both READ and line-cited; no journal
paper exists (Kaggle-2010 provenance noted as NOT independently
verifiable). PyO3 binding + NumPy wrapper with PlayerRatings defaults.

Tests anchored to an EXECUTED faithful oracle port (S1 heterogeneous
init, S2 two-period draw/lag, S3 full knobs, S4 rdmax clamp +
prior-run continuation, S5 bval symmetry, lambda=0 contrast; 1e-12
pins), 500-rep MC invariants (#[ignore]), and five EXECUTED mutation
kills: bval drop, lambda sign flip, per-game hval scaling drop,
(lag+1)->lag, opponent-g->own-g.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Enforce the 2..=10000 n_players cap in the Python wrapper BEFORE any
  length-n allocation (High: huge n_players previously attempted the
  allocation instead of raising ValueError).
- Preserve integer fidelity for white/black player-id columns: integer
  dtypes cast directly to u64; float/object inputs are rejected at or
  above the dtype's exact-integer bound before the uint64 cast, matching
  the existing period-label contract (Medium).
- Guard u64 counter overflow in the Rust core: init_games/init_lag
  values that could overflow across the run's increments now return Err
  instead of panicking (debug) or wrapping (release) (Medium).

Regression tests: Rust st_error_contract overflow cases; Python
fidelity + pre-allocation cap cases.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Implements elom_rating: nn-seat event ratings with rank base scores,
per-period single update K*(actual - expected) with expected summing
(r_p - event mean rating)/40, constant-K or kriichi experience-decay
K factor, placing mode, and games/places/lag bookkeeping.

Normative sources (READ): CRAN PlayerRatings 1.1-0 R/ratings.R lines
739-932 (elom driver), src/ratings.c lines 45-80 (elom_c kernel),
R/ratings.R lines 1006-1020 (kriichi). No journal paper exists for
this system. Faithfully reproduces the R quirk that partial events
shrink the ORIGINAL base exactly once regardless of empty-seat count
(sbase <- basev resets inside the shrink loop, R:855-866).

REDUCED-SCOPE vs R (documented in the core header): player == -1 iff
score is NaN (jointly enforced), sorted periods required, in-event
duplicate players rejected, kriichi bounds gv > 0 and 0 < kv <= 1.

Tests: exact-value anchors E1-E9 (dyadic rationals, hand-derived
oracle executed), error contract, MC-500 permutation invariance
(#[ignore]); 6 mutation kills executed (K-scaling, event-mean,
cumulative-shrink, kriichi games-timing, tie-rank, per-event-update).
PyO3 binding elom_rating + numpy-validating Python wrapper.

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: c07fa777-25ed-478d-8d2b-31f81b4be322

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-elom

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

An unsigned id above i64::MAX wrapped to a negative value through the
int64 narrowing cast (uint64::MAX -> -1) and was silently treated as
the empty-seat sentinel instead of being rejected. Impl-review
finding 1 (Medium). Adds a regression test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Base automatically changed from seonghobae-stephenson to seonghobae-glicko2 July 31, 2026 12:38
…)) (#304)

* Add metrics_rating prediction-quality metrics (PlayerRatings metrics())

Rust core reimplementation of CRAN PlayerRatings 1.1-0 metrics()
(R/ratings.R 936-957, READ; no journal paper exists -- CRAN source is
the normative reference): per predictor column, binomial deviance on
the cap-clamped predictions and RMSE/MAE on the RAW uncapped
predictions (the R source quirk at lines 949/951), each times 100 and
optionally divided by the 0.5-constant-predictor baseline. Numerator
NaN removal is elementwise per pair; scaled baselines use the act-only
row set (a different set when the predictor column has NaNs). The bdev
baseline is implemented as the exact constant ln 2 (algebraic identity
documented in the source, sub-ulp divergence from R's summation for
non-0/1 act).

Reduced scope (documented): which/sort/digits/drop presentation
arguments are not implemented (full unrounded np x 3 matrix returned);
na.rm=FALSE not implemented; Inf, empty per-column row sets, out-of-
domain caps, and scale=TRUE with an all-0.5 act baseline are rejected
where R would recycle or emit NaN/Inf.

Tests: six exact-Fraction-oracle anchors (unscaled, cap quirk, scaled,
NaN row sets, baseline-row-set killer, two-column stride pins with
both columns pinned), error contract, MC-500 #[ignore] (scaled-vs-
unscaled column-constant ratios with bdev ratio exactly ln 2;
bitwise column-permutation invariance). Five mutation kills EXECUTED:
cap-on-mse/mae, bdev-uncapped, stride transpose, missing sqrt,
baseline pair-removal. Algebraic rearrangements (sqrt(a)/sqrt(b) vs
sqrt(a/b)) are documented as unobservable.

PyO3 binding metrics_rating (plain name) + Python wrapper with
complex/object-dtype/shape validation before casts; TestMetrics;
CHANGELOG.

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

* Reject string/bool and 0-D inputs in metrics_rating wrapper

Impl-review findings: numeric strings (plain, object-dtype, and cap
tuples) were silently parsed as floats, bool arrays were accepted as
0/1, and 0-D scalars were promoted to 1-D by ascontiguousarray before
the shape check, bypassing the documented contract. Validation now
checks dtype kind and ndim before any cast; regression tests added.

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

* Reject object-dtype np.bool_ elements in metrics_rating wrapper

Round-2 review finding: np.bool_ is not a bool subclass, so object
arrays of np.bool_ bypassed the bool rejection and cast to 0/1 floats.

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

* fix: reject object-dtype None in metrics_rating inputs

Round-3 adversarial review found that object-dtype arrays containing
None were silently cast to NaN by astype(float64), bypassing the
explicit-missing contract (missing values must be passed as np.nan).
None (and any element that is str/bytes/bool/np.bool_) is now rejected
with ValueError before the cast, with regression tests for both act
and pred paths.

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

* fix: reject masked arrays in metrics_rating inputs

Round-4 adversarial review found that np.ma.MaskedArray inputs lost
their mask through np.asarray, so masked missing values were silently
counted as observed. Masked arrays are now rejected with ValueError
before conversion; missing values must be encoded as explicit np.nan.
Regression tests cover both act and pred paths.

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