feat: add ranked season rollover policy

This commit is contained in:
Josh Creek
2026-08-31 20:29:11 +01:00
parent 1793eb7621
commit cc2cd80a01
16 changed files with 688 additions and 249 deletions
+112 -32
View File
@@ -8,37 +8,87 @@ import (
)
const (
GlickoScale = 173.7178
GlickoTau = 0.5
GlickoEpsilon = 0.000001
GlickoInitialRating = 1500.0
GlickoInitialRD = 350.0
GlickoScale = 173.7178
GlickoTau = 0.5
GlickoEpsilon = 0.000001
GlickoInitialRating = 1500.0
GlickoInitialRD = 350.0
GlickoInitialVolatility = 0.06
)
type Rating struct {
Value float64
RD float64
Volatility float64
Value float64
RD float64
Volatility float64
LastRatedAt time.Time
}
type Opponent struct {
PlayerID string
Rating Rating
Weight float64
Score float64
Rating Rating
Weight float64
Score float64
}
type RankedProfile struct {
Rating
RankedGames int
LastSeasonID string
SeasonHistory []string
}
func RankedIsProvisional(profile RankedProfile) bool { return profile.RankedGames < 10 }
// ApplySeasonRollover is idempotent by season ID. It intentionally accepts a
// ranked profile, not the shared/casual rating type, so callers cannot reset a
// casual rating accidentally. The transaction adapter must persist the
// returned profile and season ID atomically with its idempotency key.
func ApplySeasonRollover(profile RankedProfile, seasonID string) (RankedProfile, bool, error) {
if seasonID == "" {
return RankedProfile{}, false, fmt.Errorf("season ID is required")
}
if profile.RankedGames < 0 {
return RankedProfile{}, false, fmt.Errorf("ranked games cannot be negative")
}
if err := validateRating(profile.Rating); err != nil {
return RankedProfile{}, false, err
}
if profile.LastSeasonID == seasonID || containsSeason(profile.SeasonHistory, seasonID) {
return profile, false, nil
}
profile.Value = GlickoInitialRating + 0.75*(profile.Value-GlickoInitialRating)
profile.RD = math.Min(GlickoInitialRD, math.Max(200.0, profile.RD))
profile.LastSeasonID = seasonID
profile.SeasonHistory = append(append([]string(nil), profile.SeasonHistory...), seasonID)
return profile, true, nil
}
func containsSeason(history []string, seasonID string) bool {
for _, prior := range history {
if prior == seasonID {
return true
}
}
return false
}
// UpdateRating applies canonical Glicko-2 to one player's immutable pre-match
// rating snapshot. Weight is 1/3 for ranked 3v3 and 1/N for casual's N human
// opponents; bots are simply omitted by the caller.
func UpdateRating(current Rating, opponents []Opponent, now time.Time) (Rating, error) {
if err := validateRating(current); err != nil { return Rating{}, err }
if len(opponents) == 0 { return advanceInactivity(current, now), nil }
if err := validateRating(current); err != nil {
return Rating{}, err
}
if len(opponents) == 0 {
return advanceInactivity(current, now), nil
}
for _, opponent := range opponents {
if err := validateRating(opponent.Rating); err != nil { return Rating{}, err }
if opponent.Weight <= 0 || opponent.Score < 0 || opponent.Score > 1 { return Rating{}, fmt.Errorf("invalid opponent weight or score") }
if err := validateRating(opponent.Rating); err != nil {
return Rating{}, err
}
if opponent.Weight <= 0 || opponent.Score < 0 || opponent.Score > 1 {
return Rating{}, fmt.Errorf("invalid opponent weight or score")
}
}
working := advanceInactivity(current, now)
mu, phi := toScale(working.Value, working.RD)
@@ -50,11 +100,15 @@ func UpdateRating(current Rating, opponents []Opponent, now time.Time) (Rating,
varianceInverse += opponent.Weight * g * g * expected * (1 - expected)
deltaSum += opponent.Weight * g * (opponent.Score - expected)
}
if varianceInverse <= 0 { return Rating{}, fmt.Errorf("opponent information has zero variance") }
if varianceInverse <= 0 {
return Rating{}, fmt.Errorf("opponent information has zero variance")
}
v := 1 / varianceInverse
delta := v * deltaSum
sigma, err := solveVolatility(phi, v, delta, working.Volatility)
if err != nil { return Rating{}, err }
if err != nil {
return Rating{}, err
}
phiStar := math.Sqrt(phi*phi + sigma*sigma)
phiPrime := 1 / math.Sqrt(1/(phiStar*phiStar)+1/v)
muPrime := mu + phiPrime*phiPrime*deltaSum
@@ -62,35 +116,47 @@ func UpdateRating(current Rating, opponents []Opponent, now time.Time) (Rating,
}
func validateRating(r Rating) error {
if r.Value < 0 || r.RD <= 0 || r.RD > GlickoInitialRD || r.Volatility <= 0 || r.Volatility >= 1 { return fmt.Errorf("invalid rating state") }
if r.Value < 0 || r.RD <= 0 || r.RD > GlickoInitialRD || r.Volatility <= 0 || r.Volatility >= 1 {
return fmt.Errorf("invalid rating state")
}
return nil
}
func advanceInactivity(r Rating, now time.Time) Rating {
if r.LastRatedAt.IsZero() || !now.After(r.LastRatedAt) { return r }
if r.LastRatedAt.IsZero() || !now.After(r.LastRatedAt) {
return r
}
periods := int(now.Sub(r.LastRatedAt) / (24 * time.Hour))
if periods <= 0 { return r }
if periods <= 0 {
return r
}
phi := r.RD / GlickoScale
phi = math.Min(GlickoInitialRD/GlickoScale, math.Sqrt(phi*phi+float64(periods)*r.Volatility*r.Volatility))
r.RD = fromScaleRD(phi)
return r
}
func toScale(rating, rd float64) (float64, float64) { return (rating - GlickoInitialRating) / GlickoScale, rd / GlickoScale }
func fromScaleRating(mu float64) float64 { return mu*GlickoScale + GlickoInitialRating }
func fromScaleRD(phi float64) float64 { return phi * GlickoScale }
func glickoG(phi float64) float64 { return 1 / math.Sqrt(1+3*phi*phi/(math.Pi*math.Pi)) }
func toScale(rating, rd float64) (float64, float64) {
return (rating - GlickoInitialRating) / GlickoScale, rd / GlickoScale
}
func fromScaleRating(mu float64) float64 { return mu*GlickoScale + GlickoInitialRating }
func fromScaleRD(phi float64) float64 { return phi * GlickoScale }
func glickoG(phi float64) float64 { return 1 / math.Sqrt(1+3*phi*phi/(math.Pi*math.Pi)) }
func expectedScore(mu, opponentMu, g float64) float64 { return 1 / (1 + math.Exp(-g*(mu-opponentMu))) }
func solveVolatility(phi, v, delta, volatility float64) (float64, error) {
a := math.Log(volatility * volatility)
variance := delta*delta - phi*phi - v
var b float64
if variance > 0 { b = math.Log(variance) } else {
if variance > 0 {
b = math.Log(variance)
} else {
b = a - GlickoTau
for volatilityFunction(b, a, phi, v, delta) < 0 {
b -= GlickoTau
if b < -100 { return 0, fmt.Errorf("volatility bracket not found") }
if b < -100 {
return 0, fmt.Errorf("volatility bracket not found")
}
}
}
fa := volatilityFunction(a, a, phi, v, delta)
@@ -98,9 +164,15 @@ func solveVolatility(phi, v, delta, volatility float64) (float64, error) {
for math.Abs(b-a) > GlickoEpsilon {
c := a + (a-b)*fa/(fb-fa)
fc := volatilityFunction(c, a, phi, v, delta)
if fc*fb < 0 { a, fa = b, fb } else { fa /= 2 }
if fc*fb < 0 {
a, fa = b, fb
} else {
fa /= 2
}
b, fb = c, fc
if math.IsNaN(b) || math.IsInf(b, 0) { return 0, fmt.Errorf("volatility iteration diverged") }
if math.IsNaN(b) || math.IsInf(b, 0) {
return 0, fmt.Errorf("volatility iteration diverged")
}
}
return math.Exp(a / 2), nil
}
@@ -115,21 +187,29 @@ func volatilityFunction(x, a, phi, v, delta float64) float64 {
// opponents. CasualOpponents assigns 1/N; both return lexical order so a
// database row-order change cannot affect floating-point accumulation order.
func RankedOpponents(opponents []Opponent) ([]Opponent, error) {
if len(opponents) != 3 { return nil, fmt.Errorf("ranked 3v3 requires three opponents") }
if len(opponents) != 3 {
return nil, fmt.Errorf("ranked 3v3 requires three opponents")
}
return weightedOpponents(opponents, 1.0/3.0), nil
}
func CasualOpponents(opponents []Opponent) ([]Opponent, error) {
if len(opponents) == 0 { return nil, nil }
if len(opponents) == 0 {
return nil, nil
}
return weightedOpponents(opponents, 1/float64(len(opponents))), nil
}
func weightedOpponents(opponents []Opponent, weight float64) []Opponent {
result := append([]Opponent(nil), opponents...)
sort.Slice(result, func(i, j int) bool {
if result[i].Rating.Value != result[j].Rating.Value { return result[i].Rating.Value < result[j].Rating.Value }
if result[i].Rating.Value != result[j].Rating.Value {
return result[i].Rating.Value < result[j].Rating.Value
}
return result[i].PlayerID < result[j].PlayerID
})
for i := range result { result[i].Weight = weight }
for i := range result {
result[i].Weight = weight
}
return result
}