Skip to content

Commit

Permalink
move Elo and Glicko to a new rating module, v16.5.0
Browse files Browse the repository at this point in the history
  • Loading branch information
ornicar committed Nov 20, 2024
1 parent 166e93e commit f83ce36
Show file tree
Hide file tree
Showing 14 changed files with 96 additions and 96 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.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", "")))),
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.newtypes.*
import scalalib.extensions.*

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
Original file line number Diff line number Diff line change
@@ -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]] =
Expand All @@ -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.*
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package chess.glicko.impl
package chess.rating.glicko.impl

final class Rating(
var rating: Double,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package chess.glicko.impl
package chess.rating.glicko.impl

import scalalib.extensions.ifTrue
import scalalib.newtypes.OpaqueDouble
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package chess.glicko.impl
package chess.rating.glicko.impl

trait Result:

Expand Down
41 changes: 41 additions & 0 deletions rating/src/main/scala/glicko/model.scala
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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]:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
package chess
package rating

class EloTest extends ChessTest:

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Expand Down

0 comments on commit f83ce36

Please sign in to comment.