Files
qrank/src/elo.go

144 lines
3.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package elo implements a practical Elo rating update for head-to-head sports
// such as table soccer. This variant has:
// - No home/away asymmetry
// - Margin of victory (MoV) factor using a robust bounded form
// - Simple burn-in: higher K for the first N games
// - No draws (inputs must produce a clear winner)
// - Rating floor
// - Per-match delta cap
// - No time weighting and no explicit uncertainty modeling
package main
import (
"errors"
"math"
)
var eloCfg = Config{
Mu0: 1500,
Scale: 400,
KNew: 48,
KStd: 24,
BurnInGames: 20,
RatingFloor: 500,
MaxPerMatchDelta: 60,
MaxGoals: 10,
}
type Config struct {
Mu0 float64
Scale float64 // KNew applies to players during burn-in (first BurnInGames matches).
KNew float64
KStd float64
BurnInGames int
RatingFloor float64
MaxPerMatchDelta float64
MaxGoals int
}
type Team struct {
Players []Player
Score int
AverageRating float64
}
// Collect players by side
type Player struct {
u *User
side string
dElo float64
}
func NewTeam(players []Player, score int) *Team {
var t1s float64 = 0
for _, player := range players {
t1s += float64(player.u.Elo)
}
t1a := t1s / float64(len(players))
return &Team{Players: players, Score: score, AverageRating: t1a}
}
func GetNewElo(teams []*Team) error {
// Update the teams
t1 := teams[0]
t2 := teams[1]
if t2.Score < 0 || t1.Score < 0 || t2.Score > eloCfg.MaxGoals || t1.Score > eloCfg.MaxGoals {
return errors.New("goals out of allowed range")
}
if t2.Score == t1.Score {
return errors.New("draws are not supported by this variant")
}
// Expected score (BradleyTerry with logistic base-10)
for i, t := range teams {
otherTeam := teams[1-i]
for i, p := range t.Players {
newRating, err := CalculateRating(p.u.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.u.GameCount)
if err != nil {
return err
}
t.Players[i].u.Elo = roundFloat(newRating, 1)
}
}
return nil
}
func CalculateRating(rA, rB float64, goalsA, goalsB, gamesA int) (newrA float64, err error) {
// Observed score (no draws)
var sA float64
if goalsA > goalsB {
sA = 1.0
} else {
sA = 0.0
}
// Margin of victory factor with clamped goal difference
diff := goalsA - goalsB
if diff < 0 {
diff = -diff
}
if diff < 1 {
diff = 1
}
if diff > eloCfg.MaxGoals {
diff = eloCfg.MaxGoals
}
// Raw delta before caps
eA := expected(eloCfg.Scale, rA, rB)
mov := movFactor(rA, rB, diff)
var Keff float64
if gamesA <= eloCfg.BurnInGames {
Keff = eloCfg.KNew
} else {
Keff = eloCfg.KStd
}
delta := Keff * mov * (sA - eA)
// Apply symmetric per-match cap
if delta > eloCfg.MaxPerMatchDelta {
delta = eloCfg.MaxPerMatchDelta
} else if delta < -eloCfg.MaxPerMatchDelta {
delta = -eloCfg.MaxPerMatchDelta
}
return rA + delta, nil
}
// expected returns the win probability for player A against B
func expected(scale, rA, rB float64) float64 {
return 1.0 / (1.0 + math.Pow(10.0, -(rA-rB)/scale))
}
// movFactor returns a bounded margin-of-victory multiplier.
func movFactor(rA, rB float64, diff int) float64 {
fd := float64(diff)
return math.Log(fd+1.0) * 2.2 / (math.Abs(rA-rB)*0.001 + 2.2)
}