Skip to content

Commit

Permalink
implement and test pure API to glicko rating computation
Browse files Browse the repository at this point in the history
for a single game only.

multiple games require some way to uniquely identify players.
  • Loading branch information
ornicar committed Nov 19, 2024
1 parent cc5b9f5 commit 81aad8f
Show file tree
Hide file tree
Showing 5 changed files with 222 additions and 29 deletions.
73 changes: 72 additions & 1 deletion core/src/main/scala/glicko/glicko.scala
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
package chess.glicko
package chess
package glicko

import java.time.Instant

Expand All @@ -9,3 +10,73 @@ case class Player(
numberOfResults: Int,
lastRatingPeriodEnd: Option[Instant] = None
)

case class Game(players: ByColor[Player], outcome: Outcome)

case class Config(
tau: impl.Tau = impl.Tau.default,
ratingPeriodsPerDay: impl.RatingPeriodsPerDay = impl.RatingPeriodsPerDay.default
)

/* Purely functional interface hiding the mutable implementation */
trait GlickoCalculatorApi:

/** Apply rating calculations and return updated players.
* Note that players who did not compete during the rating period will have see their deviation increase.
* This requires players to have some sort of unique identifier.
*/
// def computeGames(
// games: List[Game],
// skipDeviationIncrease: Boolean = false
// ): List[Player]

// Simpler use case: a single game
def computeGame(game: Game, skipDeviationIncrease: Boolean = false): ByColor[Player]

/** This is the formula defined in step 6. It is also used for players who have not competed during the rating period.
*/
// def previewDeviation(player: Player, ratingPeriodEndDate: Instant, reverse: Boolean): Double

final class GlickoCalculator(config: Config) extends GlickoCalculatorApi:

private val calculator = chess.glicko.impl.RatingCalculator(config.tau, config.ratingPeriodsPerDay)

// Simpler use case: a single game
def computeGame(game: Game, skipDeviationIncrease: Boolean = false): ByColor[Player] =
val ratings = game.players.map(conversions.toRating)
val gameResult = conversions.toGameResult(ratings, game.outcome)
val periodResults = impl.GameRatingPeriodResults(List(gameResult))
println(gameResult)
calculator.updateRatings(periodResults, skipDeviationIncrease)
println(ratings)
ratings.map(conversions.toPlayer)

private object conversions:

import impl.*

def toGameResult(ratings: ByColor[Rating], outcome: Outcome): GameResult =
outcome.winner match
case None => GameResult(ratings.white, ratings.black, true)
case Some(White) => GameResult(ratings.white, ratings.black, false)
case Some(Black) => GameResult(ratings.black, ratings.white, false)

def gamesToPeriodResults(games: List[Game]) = GameRatingPeriodResults:
games.map: game =>
toGameResult(game.players.map(toRating), game.outcome)

def toRating(player: Player) = impl.Rating(
rating = player.rating,
ratingDeviation = player.ratingDeviation,
volatility = player.volatility,
numberOfResults = player.numberOfResults,
lastRatingPeriodEnd = player.lastRatingPeriodEnd
)

def toPlayer(rating: Rating) = Player(
rating = rating.rating,
ratingDeviation = rating.ratingDeviation,
volatility = rating.volatility,
numberOfResults = rating.numberOfResults,
lastRatingPeriodEnd = rating.lastRatingPeriodEnd
)
2 changes: 1 addition & 1 deletion core/src/main/scala/glicko/impl/Rating.scala
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ final class Rating(
workingRating = 0d
workingVolatility = 0d

override def toString = s"$rating / $ratingDeviation / $volatility / $numberOfResults"
override def toString = f"Rating($rating%1.2f, $ratingDeviation%1.2f, $volatility%1.2f, $numberOfResults)"

def incrementNumberOfResults(increment: Int) =
numberOfResults = numberOfResults + increment
1 change: 0 additions & 1 deletion core/src/main/scala/glicko/impl/RatingCalculator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ opaque type RatingPeriodsPerDay = Double
object RatingPeriodsPerDay extends OpaqueDouble[RatingPeriodsPerDay]:
val default: RatingPeriodsPerDay = 0d

// rewrite from java https://github.com/goochjs/glicko2
object RatingCalculator:

private val MULTIPLIER: Double = 173.7178
Expand Down
135 changes: 135 additions & 0 deletions test-kit/src/test/scala/glicko/GlickoCalculator.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package chess
package glicko

import munit.ScalaCheckSuite

class GlickoCalculatorTest extends ScalaCheckSuite with MunitExtensions:

val calc = GlickoCalculator:
Config(
ratingPeriodsPerDay = impl.RatingPeriodsPerDay(0.21436d)
)

def computeGame(players: ByColor[Player], outcome: Outcome) =
calc.computeGame(Game(players, outcome), skipDeviationIncrease = true).toPair

{
val players = ByColor.fill:
Player(
rating = 1500d,
ratingDeviation = 500d,
volatility = 0.09d,
numberOfResults = 0,
lastRatingPeriodEnd = None
)
test("default deviation: white wins"):
val (w, b) = computeGame(players, Outcome.white)
assertCloseTo(w.rating, 1741d, 1d)
assertCloseTo(b.rating, 1258d, 1d)
assertCloseTo(w.ratingDeviation, 396d, 1d)
assertCloseTo(b.ratingDeviation, 396d, 1d)
assertCloseTo(w.volatility, 0.0899983, 0.00000001d)
assertCloseTo(b.volatility, 0.0899983, 0.0000001d)
test("default deviation: black wins"):
val (w, b) = computeGame(players, Outcome.black)
assertCloseTo(w.rating, 1258d, 1d)
assertCloseTo(b.rating, 1741d, 1d)
assertCloseTo(w.ratingDeviation, 396d, 1d)
assertCloseTo(b.ratingDeviation, 396d, 1d)
assertCloseTo(w.volatility, 0.0899983, 0.00000001d)
assertCloseTo(b.volatility, 0.0899983, 0.0000001d)
test("default deviation: draw"):
val (w, b) = computeGame(players, Outcome.draw)
assertCloseTo(w.rating, 1500d, 1d)
assertCloseTo(b.rating, 1500d, 1d)
assertCloseTo(w.ratingDeviation, 396d, 1d)
assertCloseTo(b.ratingDeviation, 396d, 1d)
assertCloseTo(w.volatility, 0.0899954, 0.0000001d)
assertCloseTo(b.volatility, 0.0899954, 0.0000001d)
}

{
val players = ByColor(
Player(
rating = 1400d,
ratingDeviation = 79d,
volatility = 0.06d,
numberOfResults = 0,
lastRatingPeriodEnd = None
),
Player(
rating = 1550d,
ratingDeviation = 110d,
volatility = 0.065d,
numberOfResults = 0,
lastRatingPeriodEnd = None
)
)
test("mixed ratings and deviations: white wins"):
val (w, b) = computeGame(players, Outcome.white)
assertCloseTo(w.rating, 1422d, 1d)
assertCloseTo(b.rating, 1506d, 1d)
assertCloseTo(w.ratingDeviation, 77d, 1d)
assertCloseTo(b.ratingDeviation, 105d, 1d)
assertCloseTo(w.volatility, 0.06, 0.00001d)
assertCloseTo(b.volatility, 0.065, 0.00001d)
test("mixed ratings and deviations: black wins"):
val (w, b) = computeGame(players, Outcome.black)
assertCloseTo(w.rating, 1389d, 1d)
assertCloseTo(b.rating, 1568d, 1d)
assertCloseTo(w.ratingDeviation, 78d, 1d)
assertCloseTo(b.ratingDeviation, 105d, 1d)
assertCloseTo(w.volatility, 0.06, 0.00001d)
assertCloseTo(b.volatility, 0.065, 0.00001d)
test("mixed ratings and deviations: draw"):
val (w, b) = computeGame(players, Outcome.draw)
assertCloseTo(w.rating, 1406d, 1d)
assertCloseTo(b.rating, 1537d, 1d)
assertCloseTo(w.ratingDeviation, 78d, 1d)
assertCloseTo(b.ratingDeviation, 105.87d, 0.01d)
assertCloseTo(w.volatility, 0.06, 0.00001d)
assertCloseTo(b.volatility, 0.065, 0.00001d)
}

{
val players = ByColor(
Player(
rating = 1200d,
ratingDeviation = 60d,
volatility = 0.053d,
numberOfResults = 0,
lastRatingPeriodEnd = None
),
Player(
rating = 1850d,
ratingDeviation = 200d,
volatility = 0.062d,
numberOfResults = 0,
lastRatingPeriodEnd = None
)
)
test("more mixed ratings and deviations: white wins"):
val (w, b) = computeGame(players, Outcome.white)
assertCloseTo(w.rating, 1216.7d, 0.1d)
assertCloseTo(b.rating, 1636d, 0.1d)
assertCloseTo(w.ratingDeviation, 59.9d, 0.1d)
assertCloseTo(b.ratingDeviation, 196.9d, 0.1d)
assertCloseTo(w.volatility, 0.053013, 0.000001d)
assertCloseTo(b.volatility, 0.062028, 0.000001d)
test("more mixed ratings and deviations: black wins"):
val (w, b) = computeGame(players, Outcome.black)
assertCloseTo(w.rating, 1199.3d, 0.1d)
assertCloseTo(b.rating, 1855.4d, 0.1d)
assertCloseTo(w.ratingDeviation, 59.9d, 0.1d)
assertCloseTo(b.ratingDeviation, 196.9d, 0.1d)
assertCloseTo(w.volatility, 0.052999, 0.000001d)
assertCloseTo(b.volatility, 0.061999, 0.000001d)
test("more mixed ratings and deviations: draw"):
val (w, b) = computeGame(players, Outcome.draw)
assertCloseTo(w.rating, 1208.0, 0.1d)
assertCloseTo(b.rating, 1745.7, 0.1d)
assertCloseTo(w.ratingDeviation, 59.90056, 0.1d)
assertCloseTo(b.ratingDeviation, 196.98729, 0.1d)
assertCloseTo(w.volatility, 0.053002, 0.000001d)
assertCloseTo(b.volatility, 0.062006, 0.000001d)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
// Chosen so a typical player's RD goes from 60 -> 110 in 1 year
val ratingPeriodsPerDay = RatingPeriodsPerDay(0.21436d)

val system = RatingCalculator(Tau.default, ratingPeriodsPerDay)
val calculator = RatingCalculator(Tau.default, ratingPeriodsPerDay)

def updateRatings(wRating: Rating, bRating: Rating, outcome: Outcome) =
val results = GameRatingPeriodResults:
Expand All @@ -18,7 +18,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
case None => GameResult(wRating, bRating, true)
case Some(White) => GameResult(wRating, bRating, false)
case Some(Black) => GameResult(bRating, wRating, false)
system.updateRatings(results, true)
calculator.updateRatings(results, true)

def defaultRating = Rating(
rating = 1500d,
Expand All @@ -28,7 +28,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
lastRatingPeriodEnd = None
)

test("default deviation: white wins") {
test("default deviation: white wins"):
val wr = defaultRating
val br = defaultRating
updateRatings(wr, br, Outcome.white)
Expand All @@ -38,8 +38,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 396d, 1d)
assertCloseTo(wr.volatility, 0.0899983, 0.00000001d)
assertCloseTo(br.volatility, 0.0899983, 0.0000001d)
}
test("default deviation: black wins") {
test("default deviation: black wins"):
val wr = defaultRating
val br = defaultRating
updateRatings(wr, br, Outcome.black)
Expand All @@ -49,8 +48,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 396d, 1d)
assertCloseTo(wr.volatility, 0.0899983, 0.00000001d)
assertCloseTo(br.volatility, 0.0899983, 0.0000001d)
}
test("default deviation: draw") {
test("default deviation: draw"):
val wr = defaultRating
val br = defaultRating
updateRatings(wr, br, Outcome.draw)
Expand All @@ -60,7 +58,6 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 396d, 1d)
assertCloseTo(wr.volatility, 0.0899954, 0.0000001d)
assertCloseTo(br.volatility, 0.0899954, 0.0000001d)
}

def oldRating = Rating(
rating = 1500d,
Expand All @@ -69,7 +66,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
numberOfResults = 0,
lastRatingPeriodEnd = None
)
test("low deviation: white wins") {
test("low deviation: white wins"):
val wr = oldRating
val br = oldRating
updateRatings(wr, br, Outcome.white)
Expand All @@ -79,8 +76,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 78d, 1d)
assertCloseTo(wr.volatility, 0.06, 0.00001d)
assertCloseTo(br.volatility, 0.06, 0.00001d)
}
test("low deviation: black wins") {
test("low deviation: black wins"):
val wr = oldRating
val br = oldRating
updateRatings(wr, br, Outcome.black)
Expand All @@ -90,8 +86,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 78d, 1d)
assertCloseTo(wr.volatility, 0.06, 0.00001d)
assertCloseTo(br.volatility, 0.06, 0.00001d)
}
test("low deviation: draw") {
test("low deviation: draw"):
val wr = oldRating
val br = oldRating
updateRatings(wr, br, Outcome.draw)
Expand All @@ -101,7 +96,6 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 78d, 1d)
assertCloseTo(wr.volatility, 0.06, 0.00001d)
assertCloseTo(br.volatility, 0.06, 0.00001d)
}
{
def whiteRating = Rating(
rating = 1400d,
Expand All @@ -117,7 +111,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
numberOfResults = 0,
lastRatingPeriodEnd = None
)
test("mixed ratings and deviations: white wins") {
test("mixed ratings and deviations: white wins"):
val wr = whiteRating
val br = blackRating
updateRatings(wr, br, Outcome.white)
Expand All @@ -127,8 +121,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 105d, 1d)
assertCloseTo(wr.volatility, 0.06, 0.00001d)
assertCloseTo(br.volatility, 0.065, 0.00001d)
}
test("mixed ratings and deviations: black wins") {
test("mixed ratings and deviations: black wins"):
val wr = whiteRating
val br = blackRating
updateRatings(wr, br, Outcome.black)
Expand All @@ -138,8 +131,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 105d, 1d)
assertCloseTo(wr.volatility, 0.06, 0.00001d)
assertCloseTo(br.volatility, 0.065, 0.00001d)
}
test("mixed ratings and deviations: draw") {
test("mixed ratings and deviations: draw"):
val wr = whiteRating
val br = blackRating
updateRatings(wr, br, Outcome.draw)
Expand All @@ -149,7 +141,6 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 105.87d, 0.01d)
assertCloseTo(wr.volatility, 0.06, 0.00001d)
assertCloseTo(br.volatility, 0.065, 0.00001d)
}
}
{
def whiteRating = Rating(
Expand All @@ -166,7 +157,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
numberOfResults = 0,
lastRatingPeriodEnd = None
)
test("more mixed ratings and deviations: white wins") {
test("more mixed ratings and deviations: white wins"):
val wr = whiteRating
val br = blackRating
updateRatings(wr, br, Outcome.white)
Expand All @@ -176,8 +167,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 196.9d, 0.1d)
assertCloseTo(wr.volatility, 0.053013, 0.000001d)
assertCloseTo(br.volatility, 0.062028, 0.000001d)
}
test("more mixed ratings and deviations: black wins") {
test("more mixed ratings and deviations: black wins"):
val wr = whiteRating
val br = blackRating
updateRatings(wr, br, Outcome.black)
Expand All @@ -187,8 +177,7 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 196.9d, 0.1d)
assertCloseTo(wr.volatility, 0.052999, 0.000001d)
assertCloseTo(br.volatility, 0.061999, 0.000001d)
}
test("more mixed ratings and deviations: draw") {
test("more mixed ratings and deviations: draw"):
val wr = whiteRating
val br = blackRating
updateRatings(wr, br, Outcome.draw)
Expand All @@ -198,5 +187,4 @@ class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions:
assertCloseTo(br.ratingDeviation, 196.98729, 0.1d)
assertCloseTo(wr.volatility, 0.053002, 0.000001d)
assertCloseTo(br.volatility, 0.062006, 0.000001d)
}
}

0 comments on commit 81aad8f

Please sign in to comment.