From 81aad8f9b57aedd0ccd7447e38188c954cbaefb1 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Tue, 19 Nov 2024 10:25:27 +0100 Subject: [PATCH] implement and test pure API to glicko rating computation for a single game only. multiple games require some way to uniquely identify players. --- core/src/main/scala/glicko/glicko.scala | 73 +++++++++- core/src/main/scala/glicko/impl/Rating.scala | 2 +- .../scala/glicko/impl/RatingCalculator.scala | 1 - .../test/scala/glicko/GlickoCalculator.scala | 135 ++++++++++++++++++ .../{ => impl}/RatingCalculatorTest.scala | 40 ++---- 5 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 test-kit/src/test/scala/glicko/GlickoCalculator.scala rename test-kit/src/test/scala/glicko/{ => impl}/RatingCalculatorTest.scala (89%) diff --git a/core/src/main/scala/glicko/glicko.scala b/core/src/main/scala/glicko/glicko.scala index ee47637d..5ea315e0 100644 --- a/core/src/main/scala/glicko/glicko.scala +++ b/core/src/main/scala/glicko/glicko.scala @@ -1,4 +1,5 @@ -package chess.glicko +package chess +package glicko import java.time.Instant @@ -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 + ) diff --git a/core/src/main/scala/glicko/impl/Rating.scala b/core/src/main/scala/glicko/impl/Rating.scala index 9ca796fd..34901127 100644 --- a/core/src/main/scala/glicko/impl/Rating.scala +++ b/core/src/main/scala/glicko/impl/Rating.scala @@ -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 diff --git a/core/src/main/scala/glicko/impl/RatingCalculator.scala b/core/src/main/scala/glicko/impl/RatingCalculator.scala index ca16a343..03c2c909 100644 --- a/core/src/main/scala/glicko/impl/RatingCalculator.scala +++ b/core/src/main/scala/glicko/impl/RatingCalculator.scala @@ -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 diff --git a/test-kit/src/test/scala/glicko/GlickoCalculator.scala b/test-kit/src/test/scala/glicko/GlickoCalculator.scala new file mode 100644 index 00000000..91899c4c --- /dev/null +++ b/test-kit/src/test/scala/glicko/GlickoCalculator.scala @@ -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) + } diff --git a/test-kit/src/test/scala/glicko/RatingCalculatorTest.scala b/test-kit/src/test/scala/glicko/impl/RatingCalculatorTest.scala similarity index 89% rename from test-kit/src/test/scala/glicko/RatingCalculatorTest.scala rename to test-kit/src/test/scala/glicko/impl/RatingCalculatorTest.scala index ee194d32..4de82fde 100644 --- a/test-kit/src/test/scala/glicko/RatingCalculatorTest.scala +++ b/test-kit/src/test/scala/glicko/impl/RatingCalculatorTest.scala @@ -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: @@ -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, @@ -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) @@ -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) @@ -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) @@ -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, @@ -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) @@ -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) @@ -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) @@ -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, @@ -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) @@ -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) @@ -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) @@ -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( @@ -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) @@ -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) @@ -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) @@ -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) - } }