From f83ce3636ab3d059fe4a90e4f05b92885fe60468 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 20 Nov 2024 09:07:05 +0100 Subject: [PATCH] move Elo and Glicko to a new rating module, v16.5.0 --- build.sbt | 13 +++- core/src/main/scala/format/pgn/Tag.scala | 5 +- core/src/main/scala/model.scala | 3 + {core => rating}/src/main/scala/Elo.scala | 12 +-- .../main/scala/glicko/GlickoCalculator.scala | 78 ++++--------------- .../src/main/scala/glicko/impl/README.md | 4 +- .../src/main/scala/glicko/impl/Rating.scala | 2 +- .../scala/glicko/impl/RatingCalculator.scala | 2 +- .../src/main/scala/glicko/impl/results.scala | 2 +- rating/src/main/scala/glicko/model.scala | 41 ++++++++++ .../src/main/scala/model.scala | 8 +- .../src/test/scala/{ => rating}/EloTest.scala | 1 + .../glicko/GlickoCalculator.scala | 14 ++-- .../glicko/impl/RatingCalculatorTest.scala | 7 +- 14 files changed, 96 insertions(+), 96 deletions(-) rename {core => rating}/src/main/scala/Elo.scala (93%) rename core/src/main/scala/glicko/glicko.scala => rating/src/main/scala/glicko/GlickoCalculator.scala (51%) rename {core => rating}/src/main/scala/glicko/impl/README.md (61%) rename {core => rating}/src/main/scala/glicko/impl/Rating.scala (98%) rename {core => rating}/src/main/scala/glicko/impl/RatingCalculator.scala (99%) rename {core => rating}/src/main/scala/glicko/impl/results.scala (98%) create mode 100644 rating/src/main/scala/glicko/model.scala rename core/src/main/scala/glicko/rating.scala => rating/src/main/scala/model.scala (67%) rename test-kit/src/test/scala/{ => rating}/EloTest.scala (99%) rename test-kit/src/test/scala/{ => rating}/glicko/GlickoCalculator.scala (95%) rename test-kit/src/test/scala/{ => rating}/glicko/impl/RatingCalculatorTest.scala (97%) diff --git a/build.sbt b/build.sbt index 3dc915180..eb2fc32ea 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,7 @@ inThisBuild( Seq( scalaVersion := "3.5.2", - version := "16.4.1", + 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..1f0f1a2a7 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.newtypes.* +import scalalib.extensions.* 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/core/src/main/scala/glicko/glicko.scala b/rating/src/main/scala/glicko/GlickoCalculator.scala similarity index 51% rename from core/src/main/scala/glicko/glicko.scala rename to rating/src/main/scala/glicko/GlickoCalculator.scala index e1e378680..a0b7aa60e 100644 --- a/core/src/main/scala/glicko/glicko.scala +++ b/rating/src/main/scala/glicko/GlickoCalculator.scala @@ -1,72 +1,17 @@ -package chess +package chess.rating package glicko import java.time.Instant +import chess.{ ByColor, Outcome, White, Black } import scala.util.Try -case class Glicko( - rating: Double, - deviation: Double, - volatility: Double -): - def intRating: IntRating = IntRating(rating.toInt) - def intDeviation = deviation.toInt - def provisional = RatingProvisional(deviation >= Glicko.provisionalDeviation) - def established = provisional.no - def establishedIntRating = Option.when(established)(intRating) - def clueless = deviation >= Glicko.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" - -object Glicko: - 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) - -case class Config( +/* Purely functional interface hiding the mutable implementation */ +final class GlickoCalculator( 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): Try[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) + private val calculator = impl.RatingCalculator(tau, ratingPeriodsPerDay) // Simpler use case: a single game def computeGame(game: Game, skipDeviationIncrease: Boolean = false): Try[ByColor[Player]] = @@ -77,9 +22,16 @@ final class GlickoCalculator(config: Config) extends GlickoCalculatorApi: 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.* @@ -90,10 +42,6 @@ final class GlickoCalculator(config: Config) extends GlickoCalculatorApi: 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.deviation, diff --git a/core/src/main/scala/glicko/impl/README.md b/rating/src/main/scala/glicko/impl/README.md similarity index 61% rename from core/src/main/scala/glicko/impl/README.md rename to rating/src/main/scala/glicko/impl/README.md index bc97ba577..ef319d102 100644 --- a/core/src/main/scala/glicko/impl/README.md +++ b/rating/src/main/scala/glicko/impl/README.md @@ -1,4 +1,4 @@ Loosely ported from java: https://github.com/goochjs/glicko2 -The implementation is not idiomatic scala and should not be used. -Use the public API instead. +The implementation is not idiomatic scala and should not be used directly. +Use the public API `chess.rating.glicko.GlickoCalculator` instead. diff --git a/core/src/main/scala/glicko/impl/Rating.scala b/rating/src/main/scala/glicko/impl/Rating.scala similarity index 98% rename from core/src/main/scala/glicko/impl/Rating.scala rename to rating/src/main/scala/glicko/impl/Rating.scala index 34901127f..5bebaeac7 100644 --- a/core/src/main/scala/glicko/impl/Rating.scala +++ b/rating/src/main/scala/glicko/impl/Rating.scala @@ -1,4 +1,4 @@ -package chess.glicko.impl +package chess.rating.glicko.impl final class Rating( var rating: Double, diff --git a/core/src/main/scala/glicko/impl/RatingCalculator.scala b/rating/src/main/scala/glicko/impl/RatingCalculator.scala similarity index 99% rename from core/src/main/scala/glicko/impl/RatingCalculator.scala rename to rating/src/main/scala/glicko/impl/RatingCalculator.scala index 03c2c909b..b0f651be9 100644 --- a/core/src/main/scala/glicko/impl/RatingCalculator.scala +++ b/rating/src/main/scala/glicko/impl/RatingCalculator.scala @@ -1,4 +1,4 @@ -package chess.glicko.impl +package chess.rating.glicko.impl import scalalib.extensions.ifTrue import scalalib.newtypes.OpaqueDouble diff --git a/core/src/main/scala/glicko/impl/results.scala b/rating/src/main/scala/glicko/impl/results.scala similarity index 98% rename from core/src/main/scala/glicko/impl/results.scala rename to rating/src/main/scala/glicko/impl/results.scala index 7e4b6528b..59d972be4 100644 --- a/core/src/main/scala/glicko/impl/results.scala +++ b/rating/src/main/scala/glicko/impl/results.scala @@ -1,4 +1,4 @@ -package chess.glicko.impl +package chess.rating.glicko.impl trait Result: diff --git a/rating/src/main/scala/glicko/model.scala b/rating/src/main/scala/glicko/model.scala new file mode 100644 index 000000000..fbe8ef909 --- /dev/null +++ b/rating/src/main/scala/glicko/model.scala @@ -0,0 +1,41 @@ +package chess.rating +package glicko + +import chess.{ IntRating, ByColor, Outcome } +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 >= Glicko.provisionalDeviation) + def established = provisional.no + def establishedIntRating = Option.when(established)(intRating) + def clueless = deviation >= Glicko.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" + +object Glicko: + 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) diff --git a/core/src/main/scala/glicko/rating.scala b/rating/src/main/scala/model.scala similarity index 67% rename from core/src/main/scala/glicko/rating.scala rename to rating/src/main/scala/model.scala index 5e1eb9e8e..8fae3c5d5 100644 --- a/core/src/main/scala/glicko/rating.scala +++ b/rating/src/main/scala/model.scala @@ -1,11 +1,7 @@ -package chess -package glicko // todo move to chess.rating? +package chess.rating import alleycats.Zero - -opaque type IntRating = Int -object IntRating extends RichOpaqueInt[IntRating]: - extension (r: IntRating) def applyDiff(diff: IntRatingDiff): IntRating = r + diff.value +import scalalib.newtypes.* opaque type IntRatingDiff = Int object IntRatingDiff extends RichOpaqueInt[IntRatingDiff]: 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/glicko/GlickoCalculator.scala b/test-kit/src/test/scala/rating/glicko/GlickoCalculator.scala similarity index 95% rename from test-kit/src/test/scala/glicko/GlickoCalculator.scala rename to test-kit/src/test/scala/rating/glicko/GlickoCalculator.scala index bf0a28f8f..225193343 100644 --- a/test-kit/src/test/scala/glicko/GlickoCalculator.scala +++ b/test-kit/src/test/scala/rating/glicko/GlickoCalculator.scala @@ -1,14 +1,14 @@ -package chess -package glicko +package chess.rating.glicko import munit.ScalaCheckSuite -class GlickoCalculatorTest extends ScalaCheckSuite with MunitExtensions: +import chess.{ ByColor, Outcome } - val calc = GlickoCalculator: - Config( - ratingPeriodsPerDay = impl.RatingPeriodsPerDay(0.21436d) - ) +class GlickoCalculatorTest extends ScalaCheckSuite with chess.MunitExtensions: + + val calc = GlickoCalculator( + ratingPeriodsPerDay = impl.RatingPeriodsPerDay(0.21436d) + ) def computeGame(players: ByColor[Player], outcome: Outcome) = calc.computeGame(Game(players, outcome), skipDeviationIncrease = true).get.toPair diff --git a/test-kit/src/test/scala/glicko/impl/RatingCalculatorTest.scala b/test-kit/src/test/scala/rating/glicko/impl/RatingCalculatorTest.scala similarity index 97% rename from test-kit/src/test/scala/glicko/impl/RatingCalculatorTest.scala rename to test-kit/src/test/scala/rating/glicko/impl/RatingCalculatorTest.scala index 4de82fde8..afa7c8cb4 100644 --- a/test-kit/src/test/scala/glicko/impl/RatingCalculatorTest.scala +++ b/test-kit/src/test/scala/rating/glicko/impl/RatingCalculatorTest.scala @@ -1,10 +1,11 @@ -package chess -package glicko.impl +package chess.rating.glicko.impl import munit.ScalaCheckSuite import cats.syntax.all.* -class RatingCalculatorTest extends ScalaCheckSuite with MunitExtensions: +import chess.{ Outcome, White, Black } + +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)