Skip to content

Commit

Permalink
Merge pull request #597 from lichess-org/glicko
Browse files Browse the repository at this point in the history
import glicko implementation from lila
  • Loading branch information
ornicar authored Nov 20, 2024
2 parents 72f6072 + 5b314a3 commit a68f539
Show file tree
Hide file tree
Showing 14 changed files with 797 additions and 10 deletions.
13 changes: 10 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -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", "")))),
Expand Down Expand Up @@ -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")
Expand All @@ -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")
5 changes: 3 additions & 2 deletions core/src/main/scala/format/pgn/Tag.scala
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
package chess

package format.pgn

import cats.Eq
Expand Down Expand Up @@ -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 =>
Expand Down
3 changes: 3 additions & 0 deletions core/src/main/scala/model.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package chess
package chess.rating

import cats.syntax.all.*
import scalalib.extensions.*
import scalalib.newtypes.*

opaque type Elo = Int

Expand All @@ -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 =>
Expand Down Expand Up @@ -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(
Expand Down
62 changes: 62 additions & 0 deletions rating/src/main/scala/glicko/GlickoCalculator.scala
Original file line number Diff line number Diff line change
@@ -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
)
4 changes: 4 additions & 0 deletions rating/src/main/scala/glicko/impl/README.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions rating/src/main/scala/glicko/impl/Rating.scala
Original file line number Diff line number Diff line change
@@ -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)"
Loading

0 comments on commit a68f539

Please sign in to comment.