From 7af37220b03b45aae80963da83b4ee5375993f21 Mon Sep 17 00:00:00 2001 From: Felix Kloss Date: Tue, 9 Dec 2025 15:26:42 +0100 Subject: [PATCH 1/4] Log user rating changes in the database Add new table rating_change_log in which updated user ratings are logged (both after finished games and due to score decay). Small change to the score decay: Filter out users who are already at the maximum sigma. Their score wouldn't change anymore and by skipping them, we do not pollute the logs with basically meaningless entries. --- comprl/CHANGELOG.md | 2 + comprl/src/comprl/server/__main__.py | 23 +++++--- comprl/src/comprl/server/data/__init__.py | 1 + comprl/src/comprl/server/data/models.py | 18 ++++++ comprl/src/comprl/server/data/sql_backend.py | 58 +++++++++++++++++++- comprl/src/comprl/server/managers.py | 26 +++++++-- 6 files changed, 114 insertions(+), 14 deletions(-) diff --git a/comprl/CHANGELOG.md b/comprl/CHANGELOG.md index cc48a9be..3cbb835b 100644 --- a/comprl/CHANGELOG.md +++ b/comprl/CHANGELOG.md @@ -48,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Matchmaking parameters can now be reloaded from the config file at runtime by sending a SIGHUP signal to the server. - Optional score decay that gradually increases the sigma rating of inactive users. +- Log all rating changes of users (after games or due to score decay). This may be + interesting for analysis, e.g. how much the leaderboard fluctuates. ## [0.1.0] diff --git a/comprl/src/comprl/server/__main__.py b/comprl/src/comprl/server/__main__.py index f6d6f464..2d5de7fa 100644 --- a/comprl/src/comprl/server/__main__.py +++ b/comprl/src/comprl/server/__main__.py @@ -18,7 +18,7 @@ import sqlalchemy as sa from comprl.server import config, networking -from comprl.server.data import get_session, init_engine, User, Game +from comprl.server.data import get_session, init_engine, User, Game, UserData from comprl.server.data.models import DEFAULT_SIGMA from comprl.server.managers import GameManager, PlayerManager, MatchmakingManager from comprl.server.interfaces import IPlayer, IServer @@ -176,19 +176,26 @@ def _score_decay(self) -> None: log.info("Skip score decay (no games played).") return - # get all users who didn't play in the last interval + # get all users who didn't play in the last interval and who aren't at the + # maximum sigma already stmt = sa.select(User).where( + User.sigma < DEFAULT_SIGMA, ~sa.exists().where( - sa.and_( - Game.start_time >= cutoff, - sa.or_(Game.user1 == User.user_id, Game.user2 == User.user_id), - ) - ) + Game.start_time >= cutoff, + sa.or_(Game.user1 == User.user_id, Game.user2 == User.user_id), + ), ) inactive_users = session.scalars(stmt).all() for user in inactive_users: - user.sigma = min(user.sigma + conf.score_decay.delta, DEFAULT_SIGMA) + new_sigma = min(user.sigma + conf.score_decay.delta, DEFAULT_SIGMA) + UserData.update_rating( + session, + user=user, + mu=user.mu, + sigma=new_sigma, + game_id=None, + ) session.commit() diff --git a/comprl/src/comprl/server/data/__init__.py b/comprl/src/comprl/server/data/__init__.py index 710451b3..4eeb4ee3 100644 --- a/comprl/src/comprl/server/data/__init__.py +++ b/comprl/src/comprl/server/data/__init__.py @@ -1,6 +1,7 @@ from .models import ( User as User, Game as Game, + RatingChangeLog as RatingChangeLog, get_one as get_one, ) from .sql_backend import ( diff --git a/comprl/src/comprl/server/data/models.py b/comprl/src/comprl/server/data/models.py index 9f96ca45..dc56bb75 100644 --- a/comprl/src/comprl/server/data/models.py +++ b/comprl/src/comprl/server/data/models.py @@ -69,6 +69,24 @@ class Game(Base): ) +class RatingChangeLog(Base): + """Log of score changes.""" + + __tablename__ = "rating_change_log" + + id: Mapped[int] = mapped_column(init=False, primary_key=True) + timestamp: Mapped[datetime.datetime] = mapped_column(sa.DateTime) + + user_id: Mapped[int] = mapped_column(sa.ForeignKey("users.user_id")) + user_: Mapped["User"] = relationship(init=False, foreign_keys=[user_id]) + + game_id: Mapped[int | None] = mapped_column(sa.ForeignKey("games.id")) + game_: Mapped["Game"] = relationship(init=False, foreign_keys=[game_id]) + + new_mu: Mapped[float] + new_sigma: Mapped[float] + + def create_database_tables(db_path: str) -> None: """Create the database tables in the given SQLite database.""" engine = sa.create_engine(f"sqlite:///{db_path}") diff --git a/comprl/src/comprl/server/data/sql_backend.py b/comprl/src/comprl/server/data/sql_backend.py index e8a47403..36a832bd 100644 --- a/comprl/src/comprl/server/data/sql_backend.py +++ b/comprl/src/comprl/server/data/sql_backend.py @@ -6,13 +6,22 @@ import itertools import os +from datetime import datetime from typing import Sequence import bcrypt import sqlalchemy as sa from comprl.server.data.interfaces import GameEndState, GameResult, UserRole -from comprl.server.data.models import Game, User, DEFAULT_MU, DEFAULT_SIGMA +from comprl.server.data.models import ( + Game, + RatingChangeLog, + User, + DEFAULT_MU, + DEFAULT_SIGMA, + get_one, +) +from comprl.shared.types import GameID _engine: sa.Engine | None = None @@ -260,6 +269,53 @@ def get_matchmaking_parameters(user_id: int) -> tuple[float, float]: return user.mu, user.sigma + @staticmethod + def update_rating( + session: sa.orm.Session, + *, + user: int | User, + mu: float, + sigma: float, + game_id: GameID | None, + ) -> None: + """Update the rating of a user. + + Updates the my and sigma rating of a user to the new values. Also logs the + change in the score change log table. + + Args: + session: The database session. + user: The ID of the user or an already loaded user instance. + mu: The new mu value. + sigma: The new sigma value. + game_id: The ID of the game that caused the rating change (if any). + """ + if isinstance(user, User): + _user = user + else: + _user = get_one(session, User, user) + + _user.mu = mu + _user.sigma = sigma + + if game_id is not None: + # need to get the database ID of the game (not the UUID one) + game_db_id = session.execute( + sa.select(Game.id).where(Game.game_id == str(game_id)) + ).scalar_one() + else: + game_db_id = None + + session.add( + RatingChangeLog( + timestamp=datetime.now(), + user_id=_user.user_id, + game_id=game_db_id, + new_mu=mu, + new_sigma=sigma, + ) + ) + @staticmethod def reset_all_matchmaking_parameters() -> None: """Resets the matchmaking parameters of all users.""" diff --git a/comprl/src/comprl/server/managers.py b/comprl/src/comprl/server/managers.py index 70fdb87f..16b20b51 100644 --- a/comprl/src/comprl/server/managers.py +++ b/comprl/src/comprl/server/managers.py @@ -15,7 +15,13 @@ from comprl.server.interfaces import IGame, IPlayer from comprl.shared.types import GameID, PlayerID -from comprl.server.data import GameData, User, UserData, get_session, get_one +from comprl.server.data import ( + GameData, + User, + UserData, + get_one, + get_session, +) from comprl.server.data.interfaces import UserRole, GameEndState from comprl.server.config import get_config @@ -653,10 +659,20 @@ def _end_game(self, game: IGame) -> None: scores=[result.score_user_1, result.score_user_2], ) - user1.mu = p1.mu - user1.sigma = p1.sigma - user2.mu = p2.mu - user2.sigma = p2.sigma + UserData.update_rating( + session, + user=user1, + mu=p1.mu, + sigma=p1.sigma, + game_id=result.game_id, + ) + UserData.update_rating( + session, + user=user2, + mu=p2.mu, + sigma=p2.sigma, + game_id=result.game_id, + ) session.commit() From 68bc33255ccf0d34e7785d75bd64308bb1c8ced1 Mon Sep 17 00:00:00 2001 From: Felix Kloss Date: Tue, 9 Dec 2025 16:16:33 +0100 Subject: [PATCH 2/4] Adjust the create_database script so it can be used for updates It is useful to re-run the script on an existing database if the only change was adding tables. So instead of directly exiting in this case, ask for confirmation. --- comprl/src/comprl/scripts/create_database.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/comprl/src/comprl/scripts/create_database.py b/comprl/src/comprl/scripts/create_database.py index a9fd4583..15bae4f8 100644 --- a/comprl/src/comprl/scripts/create_database.py +++ b/comprl/src/comprl/scripts/create_database.py @@ -38,8 +38,11 @@ def main() -> int: db_path = config["database_path"] if pathlib.Path(db_path).exists(): - print(f"ERROR: Database '{db_path}' already exists.", file=sys.stderr) - return 1 + key = input(f"Database '{db_path}' already exists. Continue? [yN] ") + if key.lower() != "y": + print("Aborting.") + return 1 + create_database_tables(db_path) return 0 From aa48c744d77c7d082a9aa1d827391b4e1f8c0d93 Mon Sep 17 00:00:00 2001 From: Felix Kloss Date: Tue, 9 Dec 2025 16:22:58 +0100 Subject: [PATCH 3/4] Rename "*_matchmaking_parameters"-functions to "*_rating" It is much shorter and says basically the same. --- comprl/src/comprl/scripts/reset.py | 2 +- comprl/src/comprl/server/data/sql_backend.py | 4 ++-- comprl/tests/reset_database_test.py | 4 ++-- comprl/tests/user_database_test.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/comprl/src/comprl/scripts/reset.py b/comprl/src/comprl/scripts/reset.py index e74b5618..7babac3e 100644 --- a/comprl/src/comprl/scripts/reset.py +++ b/comprl/src/comprl/scripts/reset.py @@ -25,7 +25,7 @@ def reset_games(): def reset_elo(): """reset the elo in the user database: set mu=25.000 and sigma=8.333""" - UserData.reset_all_matchmaking_parameters() + UserData.reset_all_ratings() logging.info( "The matchmaking parameters have been reset to default values for all users." ) diff --git a/comprl/src/comprl/server/data/sql_backend.py b/comprl/src/comprl/server/data/sql_backend.py index 36a832bd..17fb644e 100644 --- a/comprl/src/comprl/server/data/sql_backend.py +++ b/comprl/src/comprl/server/data/sql_backend.py @@ -252,7 +252,7 @@ def get_user_by_token(access_token: str) -> User | None: return user @staticmethod - def get_matchmaking_parameters(user_id: int) -> tuple[float, float]: + def get_rating(user_id: int) -> tuple[float, float]: """ Retrieves the matchmaking parameters of a user based on their ID. @@ -317,7 +317,7 @@ def update_rating( ) @staticmethod - def reset_all_matchmaking_parameters() -> None: + def reset_all_ratings() -> None: """Resets the matchmaking parameters of all users.""" with get_session() as session: session.query(User).update({"mu": DEFAULT_MU, "sigma": DEFAULT_SIGMA}) diff --git a/comprl/tests/reset_database_test.py b/comprl/tests/reset_database_test.py index 38165358..22db180e 100644 --- a/comprl/tests/reset_database_test.py +++ b/comprl/tests/reset_database_test.py @@ -44,7 +44,7 @@ def test_reset(tmp_path): ) # verify users are added correctly - mu, sigma = UserData.get_matchmaking_parameters(user_id=userID1) + mu, sigma = UserData.get_rating(user_id=userID1) assert pytest.approx(mu) == 24.0 assert pytest.approx(sigma) == 9.333 @@ -79,7 +79,7 @@ def test_reset(tmp_path): # test for user_id in (userID1, userID2, userID3, userID4): - mu, sigma = UserData.get_matchmaking_parameters(user_id=user_id) + mu, sigma = UserData.get_rating(user_id=user_id) assert pytest.approx(mu) == 25.0, f"user_id: {user_id}" assert pytest.approx(sigma) == 8.333, f"user_id: {user_id}" diff --git a/comprl/tests/user_database_test.py b/comprl/tests/user_database_test.py index bce7b9f4..fdff5cee 100644 --- a/comprl/tests/user_database_test.py +++ b/comprl/tests/user_database_test.py @@ -42,8 +42,8 @@ def test_user_data(tmp_path): assert _user.username == user[0] set_matchmaking_parameters(user_id=user_ids[1], mu=23.0, sigma=3.0) - mu0, sigma0 = UserData.get_matchmaking_parameters(user_ids[0]) - mu1, sigma1 = UserData.get_matchmaking_parameters(user_ids[1]) + mu0, sigma0 = UserData.get_rating(user_ids[0]) + mu1, sigma1 = UserData.get_rating(user_ids[1]) # user0 was unmodified, so here the default values should be returned assert pytest.approx(mu0) == 25.0 From 8e889b8f8ee30bd1e376869afa5d2a9f10bb5fee Mon Sep 17 00:00:00 2001 From: Felix Kloss Date: Tue, 9 Dec 2025 16:37:05 +0100 Subject: [PATCH 4/4] fix: Adjust import --- comprl-web-reflex/comprl_web/reflex_local_auth/auth_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comprl-web-reflex/comprl_web/reflex_local_auth/auth_session.py b/comprl-web-reflex/comprl_web/reflex_local_auth/auth_session.py index 11f2a182..c2964795 100644 --- a/comprl-web-reflex/comprl_web/reflex_local_auth/auth_session.py +++ b/comprl-web-reflex/comprl_web/reflex_local_auth/auth_session.py @@ -1,6 +1,6 @@ import datetime -from comprl.server.data.sql_backend import Base +from comprl.server.data.models import Base import sqlalchemy as sa from sqlalchemy.orm import Mapped, mapped_column