diff --git a/build.sbt b/build.sbt index 5e65c0ff1..eb2fc32ea 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,7 @@ inThisBuild( Seq( scalaVersion := "3.5.2", - version := "16.3.4", + version := "16.5.0", organization := "org.lichess", licenses += ("MIT" -> url("https://opensource.org/licenses/MIT")), publishTo := Option(Resolver.file("file", new File(sys.props.getOrElse("publishTo", "")))), @@ -54,6 +54,13 @@ lazy val playJson: Project = Project("playJson", file("playJson")) ) .dependsOn(scalachess) +lazy val rating: Project = Project("rating", file("rating")) + .settings( + commonSettings, + name := "scalachess-rating" + ) + .dependsOn(scalachess) + lazy val bench = project .enablePlugins(JmhPlugin) .settings(commonSettings, scalacOptions -= "-Wunused:all", name := "bench") @@ -79,12 +86,12 @@ lazy val testKit = project "org.typelevel" %% "cats-laws" % "2.12.0" % Test ) ) - .dependsOn(scalachess % "compile->compile") + .dependsOn(scalachess % "compile->compile", rating % "compile->compile") lazy val root = project .in(file(".")) .settings(publish := {}, publish / skip := true) - .aggregate(scalachess, playJson, testKit, bench) + .aggregate(scalachess, rating, playJson, testKit, bench) addCommandAlias("prepare", "scalafixAll; scalafmtAll") addCommandAlias("check", "; scalafixAll --check; scalafmtCheckAll") diff --git a/core/src/main/scala/format/pgn/Tag.scala b/core/src/main/scala/format/pgn/Tag.scala index 6f708ad4c..6497e900c 100644 --- a/core/src/main/scala/format/pgn/Tag.scala +++ b/core/src/main/scala/format/pgn/Tag.scala @@ -1,4 +1,5 @@ package chess + package format.pgn import cats.Eq @@ -87,8 +88,8 @@ case class Tags(value: List[Tag]) extends AnyVal: .flatMap(_.toIntOption) def names: ByColor[Option[PlayerName]] = ByColor(apply(_.White), apply(_.Black)).map(PlayerName.from(_)) - def elos: ByColor[Option[Elo]] = ByColor(apply(_.WhiteElo), apply(_.BlackElo)).map: elo => - Elo.from(elo.flatMap(_.toIntOption)) + def ratings: ByColor[Option[IntRating]] = ByColor(apply(_.WhiteElo), apply(_.BlackElo)).map: r => + IntRating.from(r.flatMap(_.toIntOption)) def titles: ByColor[Option[PlayerTitle]] = ByColor(apply(_.WhiteTitle), apply(_.BlackTitle)).map(_.flatMap(PlayerTitle.get)) def fideIds: ByColor[Option[FideId]] = ByColor(apply(_.WhiteFideId), apply(_.BlackFideId)).map: id => diff --git a/core/src/main/scala/model.scala b/core/src/main/scala/model.scala index 92fae5b53..db33cdb53 100644 --- a/core/src/main/scala/model.scala +++ b/core/src/main/scala/model.scala @@ -40,3 +40,6 @@ object FideId extends OpaqueInt[FideId] opaque type PlayerName = String object PlayerName extends OpaqueString[PlayerName] + +opaque type IntRating = Int +object IntRating extends RichOpaqueInt[IntRating] diff --git a/core/src/main/scala/Elo.scala b/rating/src/main/scala/Elo.scala similarity index 93% rename from core/src/main/scala/Elo.scala rename to rating/src/main/scala/Elo.scala index e01c315f4..06614e914 100644 --- a/core/src/main/scala/Elo.scala +++ b/rating/src/main/scala/Elo.scala @@ -1,6 +1,8 @@ -package chess +package chess.rating import cats.syntax.all.* +import scalalib.extensions.* +import scalalib.newtypes.* opaque type Elo = Int @@ -12,10 +14,10 @@ object KFactor extends OpaqueInt[KFactor]: * https://handbook.fide.com/chapter/B022022 * https://ratings.fide.com/calc.phtml * */ -object Elo extends RelaxedOpaqueInt[Elo]: +object Elo extends RichOpaqueInt[Elo]: - def computeRatingDiff(player: Player, games: Seq[Game]): Int = - computeNewRating(player, games) - player.rating + def computeRatingDiff(player: Player, games: Seq[Game]): IntRatingDiff = + IntRatingDiff(computeNewRating(player, games) - player.rating) def computeNewRating(player: Player, games: Seq[Game]): Elo = val expectedScore = games.foldMap: game => @@ -45,7 +47,7 @@ object Elo extends RelaxedOpaqueInt[Elo]: final class Player(val rating: Elo, val kFactor: KFactor) - final class Game(val points: Outcome.Points, val opponentRating: Elo) + final class Game(val points: chess.Outcome.Points, val opponentRating: Elo) // 8.1.2 FIDE table val conversionTableFIDE: Map[Int, Float] = List( diff --git a/rating/src/main/scala/glicko/GlickoCalculator.scala b/rating/src/main/scala/glicko/GlickoCalculator.scala new file mode 100644 index 000000000..d7075683b --- /dev/null +++ b/rating/src/main/scala/glicko/GlickoCalculator.scala @@ -0,0 +1,62 @@ +package chess.rating +package glicko + +import chess.{ Black, ByColor, Outcome, White } + +import java.time.Instant +import scala.util.Try + +/* Purely functional interface hiding the mutable implementation */ +final class GlickoCalculator( + tau: Tau = Tau.default, + ratingPeriodsPerDay: RatingPeriodsPerDay = RatingPeriodsPerDay.default +): + + private val calculator = new impl.RatingCalculator(tau, ratingPeriodsPerDay) + + // Simpler use case: a single game + def computeGame(game: Game, skipDeviationIncrease: Boolean = false): Try[ByColor[Player]] = + val ratings = game.players.map(conversions.toRating) + val gameResult = conversions.toGameResult(ratings, game.outcome) + val periodResults = impl.GameRatingPeriodResults(List(gameResult)) + Try: + calculator.updateRatings(periodResults, skipDeviationIncrease) + ratings.map(conversions.toPlayer) + + /** 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 = + calculator.previewDeviation(conversions.toRating(player), ratingPeriodEndDate, reverse) + + /** 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] + + 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 toRating(player: Player) = impl.Rating( + rating = player.rating, + ratingDeviation = player.deviation, + volatility = player.volatility, + numberOfResults = player.numberOfResults, + lastRatingPeriodEnd = player.lastRatingPeriodEnd + ) + + def toPlayer(rating: Rating) = Player( + glicko = Glicko( + rating = rating.rating, + deviation = rating.ratingDeviation, + volatility = rating.volatility + ), + numberOfResults = rating.numberOfResults, + lastRatingPeriodEnd = rating.lastRatingPeriodEnd + ) diff --git a/rating/src/main/scala/glicko/impl/README.md b/rating/src/main/scala/glicko/impl/README.md new file mode 100644 index 000000000..ef319d102 --- /dev/null +++ b/rating/src/main/scala/glicko/impl/README.md @@ -0,0 +1,4 @@ +Loosely ported from java: https://github.com/goochjs/glicko2 + +The implementation is not idiomatic scala and should not be used directly. +Use the public API `chess.rating.glicko.GlickoCalculator` instead. diff --git a/rating/src/main/scala/glicko/impl/Rating.scala b/rating/src/main/scala/glicko/impl/Rating.scala new file mode 100644 index 000000000..66c82e826 --- /dev/null +++ b/rating/src/main/scala/glicko/impl/Rating.scala @@ -0,0 +1,52 @@ +package chess.rating.glicko +package impl + +final private[glicko] class Rating( + var rating: Double, + var ratingDeviation: Double, + var volatility: Double, + var numberOfResults: Int, + var lastRatingPeriodEnd: Option[java.time.Instant] = None +): + + import RatingCalculator.* + + // the following variables are used to hold values temporarily whilst running calculations + private[impl] var workingRating: Double = scala.compiletime.uninitialized + private[impl] var workingRatingDeviation: Double = scala.compiletime.uninitialized + private[impl] var workingVolatility: Double = scala.compiletime.uninitialized + + /** Return the average skill value of the player scaled down to the scale used by the algorithm's internal + * workings. + */ + private[impl] def getGlicko2Rating: Double = convertRatingToGlicko2Scale(this.rating) + + /** Set the average skill value, taking in a value in Glicko2 scale. + */ + private[impl] def setGlicko2Rating(r: Double) = + rating = convertRatingToOriginalGlickoScale(r) + + /** Return the rating deviation of the player scaled down to the scale used by the algorithm's internal + * workings. + */ + private[impl] def getGlicko2RatingDeviation: Double = convertRatingDeviationToGlicko2Scale(ratingDeviation) + + /** Set the rating deviation, taking in a value in Glicko2 scale. + */ + private[impl] def setGlicko2RatingDeviation(rd: Double) = + ratingDeviation = convertRatingDeviationToOriginalGlickoScale(rd) + + /** Used by the calculation engine, to move interim calculations into their "proper" places. + */ + private[impl] def finaliseRating() = + setGlicko2Rating(workingRating) + setGlicko2RatingDeviation(workingRatingDeviation) + volatility = workingVolatility + workingRatingDeviation = 0d + workingRating = 0d + workingVolatility = 0d + + private[impl] def incrementNumberOfResults(increment: Int) = + numberOfResults = numberOfResults + increment + + override def toString = f"Rating($rating%1.2f, $ratingDeviation%1.2f, $volatility%1.2f, $numberOfResults)" diff --git a/rating/src/main/scala/glicko/impl/RatingCalculator.scala b/rating/src/main/scala/glicko/impl/RatingCalculator.scala new file mode 100644 index 000000000..331cfb494 --- /dev/null +++ b/rating/src/main/scala/glicko/impl/RatingCalculator.scala @@ -0,0 +1,209 @@ +package chess.rating.glicko +package impl + +import scalalib.extensions.ifTrue + +import java.time.Instant + +private object RatingCalculator: + + private val MULTIPLIER: Double = 173.7178 + val DEFAULT_RATING: Double = 1500.0 + + def convertRatingToOriginalGlickoScale(rating: Double): Double = + ((rating * MULTIPLIER) + DEFAULT_RATING) + + def convertRatingToGlicko2Scale(rating: Double): Double = + ((rating - DEFAULT_RATING) / MULTIPLIER) + + def convertRatingDeviationToOriginalGlickoScale(ratingDeviation: Double): Double = + (ratingDeviation * MULTIPLIER) + + def convertRatingDeviationToGlicko2Scale(ratingDeviation: Double): Double = + (ratingDeviation / MULTIPLIER) + +final private[glicko] class RatingCalculator( + tau: Tau = Tau.default, + ratingPeriodsPerDay: RatingPeriodsPerDay = RatingPeriodsPerDay.default +): + + import RatingCalculator.* + + private val CONVERGENCE_TOLERANCE: Double = 0.000001 + private val ITERATION_MAX: Int = 1000 + private val DAYS_PER_MILLI: Double = 1.0 / (1000 * 60 * 60 * 24) + + private val ratingPeriodsPerMilli: Double = ratingPeriodsPerDay.value * DAYS_PER_MILLI + + /**

Run through all players within a resultset and calculate their new ratings.

Players within the + * resultset who did not compete during the rating period will have see their deviation increase (in line + * with Prof Glickman's paper).

Note that this method will clear the results held in the association + * resultset.

+ * + * @param results + */ + def updateRatings(results: RatingPeriodResults[?], skipDeviationIncrease: Boolean = false) = + val players = results.getParticipants + players.foreach { player => + val elapsedRatingPeriods = if skipDeviationIncrease then 0 else 1 + if results.getResults(player).sizeIs > 0 then + calculateNewRating(player, results.getResults(player), elapsedRatingPeriods) + else + // if a player does not compete during the rating period, then only Step 6 applies. + // the player's rating and volatility parameters remain the same but deviation increases + player.workingRating = player.getGlicko2Rating + player.workingRatingDeviation = + calculateNewRD(player.getGlicko2RatingDeviation, player.volatility, elapsedRatingPeriods) + player.workingVolatility = player.volatility + } + + // now iterate through the participants and confirm their new ratings + players.foreach { _.finaliseRating() } + + /** This is the formula defined in step 6. It is also used for players who have not competed during the + * rating period. + * + * @param player + * @param ratingPeriodEndDate + * @param reverse + * @return + * new rating deviation + */ + def previewDeviation(player: Rating, ratingPeriodEndDate: Instant, reverse: Boolean): Double = + var elapsedRatingPeriods = 0d + player.lastRatingPeriodEnd.ifTrue(ratingPeriodsPerMilli > 0).foreach { periodEnd => + val interval = java.time.Duration.between(periodEnd, ratingPeriodEndDate) + elapsedRatingPeriods = interval.toMillis * ratingPeriodsPerMilli + } + if reverse then elapsedRatingPeriods = -elapsedRatingPeriods + val newRD = calculateNewRD(player.getGlicko2RatingDeviation, player.volatility, elapsedRatingPeriods) + convertRatingDeviationToOriginalGlickoScale(newRD) + + /** This is the function processing described in step 5 of Glickman's paper. + * + * @param player + * @param results + * @param elapsedRatingPeriods + */ + private def calculateNewRating(player: Rating, results: List[Result], elapsedRatingPeriods: Double): Unit = + val phi = player.getGlicko2RatingDeviation + val sigma = player.volatility + val a = Math.log(Math.pow(sigma, 2)) + val delta = deltaOf(player, results) + val v = vOf(player, results) + val tau = this.tau.value + + // step 5.2 - set the initial values of the iterative algorithm to come in step 5.4 + var A: Double = a + var B: Double = 0 + if Math.pow(delta, 2) > Math.pow(phi, 2) + v then B = Math.log(Math.pow(delta, 2) - Math.pow(phi, 2) - v) + else + var k = 1d + B = a - (k * Math.abs(tau)) + + while f(B, delta, phi, v, a, tau) < 0 do + k = k + 1 + B = a - (k * Math.abs(tau)) + + // step 5.3 + var fA = f(A, delta, phi, v, a, tau) + var fB = f(B, delta, phi, v, a, tau) + + // step 5.4 + var iterations = 0 + while Math.abs(B - A) > CONVERGENCE_TOLERANCE && iterations < ITERATION_MAX do + iterations = iterations + 1 + // println(String.format("%f - %f (%f) > %f", B, A, Math.abs(B - A), CONVERGENCE_TOLERANCE)) + val C = A + (((A - B) * fA) / (fB - fA)) + val fC = f(C, delta, phi, v, a, tau) + + if fC * fB <= 0 then + A = B + fA = fB + else fA = fA / 2.0 + + B = C + fB = fC + if iterations == ITERATION_MAX then + println(String.format("Convergence fail at %d iterations", iterations)) + println(player.toString()) + results.foreach(println) + throw new RuntimeException("Convergence fail") + + val newSigma = Math.exp(A / 2.0) + + player.workingVolatility = newSigma + + // Step 6 + val phiStar = calculateNewRD(phi, newSigma, elapsedRatingPeriods) + + // Step 7 + val newPhi = 1.0 / Math.sqrt((1.0 / Math.pow(phiStar, 2)) + (1.0 / v)) + + // note that the newly calculated rating values are stored in a "working" area in the Rating object + // this avoids us attempting to calculate subsequent participants' ratings against a moving target + player.workingRating = + player.getGlicko2Rating + (Math.pow(newPhi, 2) * outcomeBasedRating(player, results)) + player.workingRatingDeviation = newPhi + player.incrementNumberOfResults(results.size) + + private def f(x: Double, delta: Double, phi: Double, v: Double, a: Double, tau: Double) = + (Math.exp(x) * (Math.pow(delta, 2) - Math.pow(phi, 2) - v - Math.exp(x)) / + (2.0 * Math.pow(Math.pow(phi, 2) + v + Math.exp(x), 2))) - + ((x - a) / Math.pow(tau, 2)) + + /** This is the first sub-function of step 3 of Glickman's paper. + */ + private def g(deviation: Double) = + 1.0 / (Math.sqrt(1.0 + (3.0 * Math.pow(deviation, 2) / Math.pow(Math.PI, 2)))) + + /** This is the second sub-function of step 3 of Glickman's paper. + */ + private def E(playerRating: Double, opponentRating: Double, opponentDeviation: Double) = + 1.0 / (1.0 + Math.exp(-1.0 * g(opponentDeviation) * (playerRating - opponentRating))) + + /** This is the main function in step 3 of Glickman's paper. + */ + private def vOf(player: Rating, results: List[Result]) = + var v = 0.0d + for result <- results do + v = v + ((Math.pow(g(result.getOpponent(player).getGlicko2RatingDeviation), 2)) + * E( + player.getGlicko2Rating, + result.getOpponent(player).getGlicko2Rating, + result.getOpponent(player).getGlicko2RatingDeviation + ) + * (1.0 - E( + player.getGlicko2Rating, + result.getOpponent(player).getGlicko2Rating, + result.getOpponent(player).getGlicko2RatingDeviation + ))) + 1 / v + + /** This is a formula as per step 4 of Glickman's paper. + */ + private def deltaOf(player: Rating, results: List[Result]): Double = + vOf(player, results) * outcomeBasedRating(player, results) + + /** This is a formula as per step 4 of Glickman's paper. + * + * @return + * expected rating based on game outcomes + */ + private def outcomeBasedRating(player: Rating, results: List[Result]) = + var outcomeBasedRating = 0d + for result <- results do + outcomeBasedRating = outcomeBasedRating + + (g(result.getOpponent(player).getGlicko2RatingDeviation) + * (result.getScore(player) - E( + player.getGlicko2Rating, + result.getOpponent(player).getGlicko2Rating, + result.getOpponent(player).getGlicko2RatingDeviation + ))) + outcomeBasedRating + + /** This is the formula defined in step 6. It is also used for players who have not competed during the + * rating period. + */ + private def calculateNewRD(phi: Double, sigma: Double, elapsedRatingPeriods: Double) = + Math.sqrt(Math.pow(phi, 2) + elapsedRatingPeriods * Math.pow(sigma, 2)) diff --git a/rating/src/main/scala/glicko/impl/results.scala b/rating/src/main/scala/glicko/impl/results.scala new file mode 100644 index 000000000..22551d7bb --- /dev/null +++ b/rating/src/main/scala/glicko/impl/results.scala @@ -0,0 +1,63 @@ +package chess.rating.glicko +package impl + +private[glicko] trait Result: + + def getScore(player: Rating): Double + + def getOpponent(player: Rating): Rating + + def participated(player: Rating): Boolean + + def players: List[Rating] + +// score from 0 (opponent wins) to 1 (player wins) +final private[glicko] class FloatingResult(player: Rating, opponent: Rating, score: Float) extends Result: + + def getScore(p: Rating) = if p == player then score else 1 - score + + def getOpponent(p: Rating) = if p == player then opponent else player + + def participated(p: Rating) = p == player || p == opponent + + def players = List(player, opponent) + +final private[glicko] class GameResult(winner: Rating, loser: Rating, isDraw: Boolean) extends Result: + private val POINTS_FOR_WIN = 1.0d + private val POINTS_FOR_LOSS = 0.0d + private val POINTS_FOR_DRAW = 0.5d + + def players = List(winner, loser) + + def participated(player: Rating) = player == winner || player == loser + + /** Returns the "score" for a match. + * + * @param player + * @return + * 1 for a win, 0.5 for a draw and 0 for a loss + * @throws IllegalArgumentException + */ + def getScore(player: Rating): Double = + if isDraw then POINTS_FOR_DRAW + else if winner == player then POINTS_FOR_WIN + else if loser == player then POINTS_FOR_LOSS + else throw new IllegalArgumentException("Player did not participate in match"); + + def getOpponent(player: Rating) = + if winner == player then loser + else if loser == player then winner + else throw new IllegalArgumentException("Player did not participate in match"); + + override def toString = s"$winner vs $loser = $isDraw" + +private[glicko] trait RatingPeriodResults[R <: Result](): + val results: List[R] + def getResults(player: Rating): List[R] = results.filter(_.participated(player)) + def getParticipants: Set[Rating] = results.flatMap(_.players).toSet + +final private[glicko] class GameRatingPeriodResults(val results: List[GameResult]) + extends RatingPeriodResults[GameResult] + +final private[glicko] class FloatingRatingPeriodResults(val results: List[FloatingResult]) + extends RatingPeriodResults[FloatingResult] diff --git a/rating/src/main/scala/glicko/model.scala b/rating/src/main/scala/glicko/model.scala new file mode 100644 index 000000000..154942154 --- /dev/null +++ b/rating/src/main/scala/glicko/model.scala @@ -0,0 +1,50 @@ +package chess.rating +package glicko + +import chess.{ ByColor, IntRating, Outcome } +import scalalib.newtypes.OpaqueDouble + +import java.time.Instant + +case class Glicko( + rating: Double, + deviation: Double, + volatility: Double +): + def intRating: IntRating = IntRating(rating.toInt) + def intDeviation = deviation.toInt + def provisional = RatingProvisional(deviation >= provisionalDeviation) + def established = provisional.no + def establishedIntRating = Option.when(established)(intRating) + def clueless = deviation >= cluelessDeviation + def display = s"$intRating${if provisional.yes then "?" else ""}" + def average(other: Glicko, weight: Float = 0.5f): Glicko = + if weight >= 1 then other + else if weight <= 0 then this + else + Glicko( + rating = rating * (1 - weight) + other.rating * weight, + deviation = deviation * (1 - weight) + other.deviation * weight, + volatility = volatility * (1 - weight) + other.volatility * weight + ) + override def toString = f"$intRating/$intDeviation/${volatility}%.3f" + +val provisionalDeviation = 110 +val cluelessDeviation = 230 + +case class Player( + glicko: Glicko, + numberOfResults: Int, + lastRatingPeriodEnd: Option[Instant] = None +): + export glicko.* + +case class Game(players: ByColor[Player], outcome: Outcome) + +opaque type Tau = Double +object Tau extends OpaqueDouble[Tau]: + val default: Tau = 0.75d + +opaque type RatingPeriodsPerDay = Double +object RatingPeriodsPerDay extends OpaqueDouble[RatingPeriodsPerDay]: + val default: RatingPeriodsPerDay = 0d diff --git a/rating/src/main/scala/model.scala b/rating/src/main/scala/model.scala new file mode 100644 index 000000000..8fae3c5d5 --- /dev/null +++ b/rating/src/main/scala/model.scala @@ -0,0 +1,18 @@ +package chess.rating + +import alleycats.Zero +import scalalib.newtypes.* + +opaque type IntRatingDiff = Int +object IntRatingDiff extends RichOpaqueInt[IntRatingDiff]: + extension (diff: IntRatingDiff) + def positive: Boolean = diff > 0 + def negative: Boolean = diff < 0 + def zero: Boolean = diff == 0 + given Zero[IntRatingDiff] = Zero(0) + +opaque type Rating = Double +object Rating extends OpaqueDouble[Rating] + +opaque type RatingProvisional = Boolean +object RatingProvisional extends YesNo[RatingProvisional] diff --git a/test-kit/src/test/scala/EloTest.scala b/test-kit/src/test/scala/rating/EloTest.scala similarity index 99% rename from test-kit/src/test/scala/EloTest.scala rename to test-kit/src/test/scala/rating/EloTest.scala index 9984b5bae..c2c7db357 100644 --- a/test-kit/src/test/scala/EloTest.scala +++ b/test-kit/src/test/scala/rating/EloTest.scala @@ -1,4 +1,5 @@ package chess +package rating class EloTest extends ChessTest: diff --git a/test-kit/src/test/scala/rating/glicko/GlickoCalculator.scala b/test-kit/src/test/scala/rating/glicko/GlickoCalculator.scala new file mode 100644 index 000000000..bd9f14afd --- /dev/null +++ b/test-kit/src/test/scala/rating/glicko/GlickoCalculator.scala @@ -0,0 +1,124 @@ +package chess.rating.glicko + +import chess.{ ByColor, Outcome } +import munit.ScalaCheckSuite + +class GlickoCalculatorTest extends ScalaCheckSuite with chess.MunitExtensions: + + val calc = GlickoCalculator( + ratingPeriodsPerDay = RatingPeriodsPerDay(0.21436d) + ) + + def computeGame(players: ByColor[Player], outcome: Outcome) = + calc.computeGame(Game(players, outcome), skipDeviationIncrease = true).get.toPair + + { + val players = ByColor.fill: + Player( + Glicko(rating = 1500d, deviation = 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.deviation, 396d, 1d) + assertCloseTo(b.deviation, 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.deviation, 396d, 1d) + assertCloseTo(b.deviation, 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.deviation, 396d, 1d) + assertCloseTo(b.deviation, 396d, 1d) + assertCloseTo(w.volatility, 0.0899954, 0.0000001d) + assertCloseTo(b.volatility, 0.0899954, 0.0000001d) + } + + { + val players = ByColor( + Player( + Glicko(rating = 1400d, deviation = 79d, volatility = 0.06d), + numberOfResults = 0, + lastRatingPeriodEnd = None + ), + Player( + Glicko(rating = 1550d, deviation = 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.deviation, 77d, 1d) + assertCloseTo(b.deviation, 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.deviation, 78d, 1d) + assertCloseTo(b.deviation, 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.deviation, 78d, 1d) + assertCloseTo(b.deviation, 105.87d, 0.01d) + assertCloseTo(w.volatility, 0.06, 0.00001d) + assertCloseTo(b.volatility, 0.065, 0.00001d) + } + + { + val players = ByColor( + Player( + Glicko(rating = 1200d, deviation = 60d, volatility = 0.053d), + numberOfResults = 0, + lastRatingPeriodEnd = None + ), + Player( + Glicko(rating = 1850d, deviation = 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.deviation, 59.9d, 0.1d) + assertCloseTo(b.deviation, 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.deviation, 59.9d, 0.1d) + assertCloseTo(b.deviation, 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.deviation, 59.90056, 0.1d) + assertCloseTo(b.deviation, 196.98729, 0.1d) + assertCloseTo(w.volatility, 0.053002, 0.000001d) + assertCloseTo(b.volatility, 0.062006, 0.000001d) + } diff --git a/test-kit/src/test/scala/rating/glicko/impl/RatingCalculatorTest.scala b/test-kit/src/test/scala/rating/glicko/impl/RatingCalculatorTest.scala new file mode 100644 index 000000000..0b44cad41 --- /dev/null +++ b/test-kit/src/test/scala/rating/glicko/impl/RatingCalculatorTest.scala @@ -0,0 +1,191 @@ +package chess.rating.glicko +package impl + +import cats.syntax.all.* +import chess.{ Black, Outcome, White } +import munit.ScalaCheckSuite + +class RatingCalculatorTest extends ScalaCheckSuite with chess.MunitExtensions: + + // Chosen so a typical player's RD goes from 60 -> 110 in 1 year + val ratingPeriodsPerDay = RatingPeriodsPerDay(0.21436d) + + val calculator = RatingCalculator(Tau.default, ratingPeriodsPerDay) + + def updateRatings(wRating: Rating, bRating: Rating, outcome: Outcome) = + val results = GameRatingPeriodResults: + List: + outcome.winner match + case None => GameResult(wRating, bRating, true) + case Some(White) => GameResult(wRating, bRating, false) + case Some(Black) => GameResult(bRating, wRating, false) + calculator.updateRatings(results, true) + + def defaultRating = Rating( + rating = 1500d, + ratingDeviation = 500d, + volatility = 0.09d, + numberOfResults = 0, + lastRatingPeriodEnd = None + ) + + test("default deviation: white wins"): + val wr = defaultRating + val br = defaultRating + updateRatings(wr, br, Outcome.white) + assertCloseTo(wr.rating, 1741d, 1d) + assertCloseTo(br.rating, 1258d, 1d) + assertCloseTo(wr.ratingDeviation, 396d, 1d) + assertCloseTo(br.ratingDeviation, 396d, 1d) + assertCloseTo(wr.volatility, 0.0899983, 0.00000001d) + assertCloseTo(br.volatility, 0.0899983, 0.0000001d) + test("default deviation: black wins"): + val wr = defaultRating + val br = defaultRating + updateRatings(wr, br, Outcome.black) + assertCloseTo(wr.rating, 1258d, 1d) + assertCloseTo(br.rating, 1741d, 1d) + assertCloseTo(wr.ratingDeviation, 396d, 1d) + assertCloseTo(br.ratingDeviation, 396d, 1d) + assertCloseTo(wr.volatility, 0.0899983, 0.00000001d) + assertCloseTo(br.volatility, 0.0899983, 0.0000001d) + test("default deviation: draw"): + val wr = defaultRating + val br = defaultRating + updateRatings(wr, br, Outcome.draw) + assertCloseTo(wr.rating, 1500d, 1d) + assertCloseTo(br.rating, 1500d, 1d) + assertCloseTo(wr.ratingDeviation, 396d, 1d) + assertCloseTo(br.ratingDeviation, 396d, 1d) + assertCloseTo(wr.volatility, 0.0899954, 0.0000001d) + assertCloseTo(br.volatility, 0.0899954, 0.0000001d) + + def oldRating = Rating( + rating = 1500d, + ratingDeviation = 80d, + volatility = 0.06d, + numberOfResults = 0, + lastRatingPeriodEnd = None + ) + test("low deviation: white wins"): + val wr = oldRating + val br = oldRating + updateRatings(wr, br, Outcome.white) + assertCloseTo(wr.rating, 1517d, 1d) + assertCloseTo(br.rating, 1482d, 1d) + assertCloseTo(wr.ratingDeviation, 78d, 1d) + assertCloseTo(br.ratingDeviation, 78d, 1d) + assertCloseTo(wr.volatility, 0.06, 0.00001d) + assertCloseTo(br.volatility, 0.06, 0.00001d) + test("low deviation: black wins"): + val wr = oldRating + val br = oldRating + updateRatings(wr, br, Outcome.black) + assertCloseTo(wr.rating, 1482d, 1d) + assertCloseTo(br.rating, 1517d, 1d) + assertCloseTo(wr.ratingDeviation, 78d, 1d) + assertCloseTo(br.ratingDeviation, 78d, 1d) + assertCloseTo(wr.volatility, 0.06, 0.00001d) + assertCloseTo(br.volatility, 0.06, 0.00001d) + test("low deviation: draw"): + val wr = oldRating + val br = oldRating + updateRatings(wr, br, Outcome.draw) + assertCloseTo(wr.rating, 1500d, 1d) + assertCloseTo(br.rating, 1500d, 1d) + assertCloseTo(wr.ratingDeviation, 78d, 1d) + assertCloseTo(br.ratingDeviation, 78d, 1d) + assertCloseTo(wr.volatility, 0.06, 0.00001d) + assertCloseTo(br.volatility, 0.06, 0.00001d) + { + def whiteRating = Rating( + rating = 1400d, + ratingDeviation = 79d, + volatility = 0.06d, + numberOfResults = 0, + lastRatingPeriodEnd = None + ) + def blackRating = Rating( + rating = 1550d, + ratingDeviation = 110d, + volatility = 0.065d, + numberOfResults = 0, + lastRatingPeriodEnd = None + ) + test("mixed ratings and deviations: white wins"): + val wr = whiteRating + val br = blackRating + updateRatings(wr, br, Outcome.white) + assertCloseTo(wr.rating, 1422d, 1d) + assertCloseTo(br.rating, 1506d, 1d) + assertCloseTo(wr.ratingDeviation, 77d, 1d) + 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"): + val wr = whiteRating + val br = blackRating + updateRatings(wr, br, Outcome.black) + assertCloseTo(wr.rating, 1389d, 1d) + assertCloseTo(br.rating, 1568d, 1d) + assertCloseTo(wr.ratingDeviation, 78d, 1d) + 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"): + val wr = whiteRating + val br = blackRating + updateRatings(wr, br, Outcome.draw) + assertCloseTo(wr.rating, 1406d, 1d) + assertCloseTo(br.rating, 1537d, 1d) + assertCloseTo(wr.ratingDeviation, 78d, 1d) + assertCloseTo(br.ratingDeviation, 105.87d, 0.01d) + assertCloseTo(wr.volatility, 0.06, 0.00001d) + assertCloseTo(br.volatility, 0.065, 0.00001d) + } + { + def whiteRating = Rating( + rating = 1200d, + ratingDeviation = 60d, + volatility = 0.053d, + numberOfResults = 0, + lastRatingPeriodEnd = None + ) + def blackRating = Rating( + rating = 1850d, + ratingDeviation = 200d, + volatility = 0.062d, + numberOfResults = 0, + lastRatingPeriodEnd = None + ) + test("more mixed ratings and deviations: white wins"): + val wr = whiteRating + val br = blackRating + updateRatings(wr, br, Outcome.white) + assertCloseTo(wr.rating, 1216.7d, 0.1d) + assertCloseTo(br.rating, 1636d, 0.1d) + assertCloseTo(wr.ratingDeviation, 59.9d, 0.1d) + 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"): + val wr = whiteRating + val br = blackRating + updateRatings(wr, br, Outcome.black) + assertCloseTo(wr.rating, 1199.3d, 0.1d) + assertCloseTo(br.rating, 1855.4d, 0.1d) + assertCloseTo(wr.ratingDeviation, 59.9d, 0.1d) + 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"): + val wr = whiteRating + val br = blackRating + updateRatings(wr, br, Outcome.draw) + assertCloseTo(wr.rating, 1208.0, 0.1d) + assertCloseTo(br.rating, 1745.7, 0.1d) + assertCloseTo(wr.ratingDeviation, 59.90056, 0.1d) + assertCloseTo(br.ratingDeviation, 196.98729, 0.1d) + assertCloseTo(wr.volatility, 0.053002, 0.000001d) + assertCloseTo(br.volatility, 0.062006, 0.000001d) + }