diff --git a/judge/admin/contest.py b/judge/admin/contest.py index 1726d88..8678281 100644 --- a/judge/admin/contest.py +++ b/judge/admin/contest.py @@ -282,7 +282,7 @@ class ContestParticipationForm(ModelForm): class ContestParticipationAdmin(admin.ModelAdmin): fields = ('contest', 'user', 'real_start', 'virtual', 'is_disqualified') - list_display = ('contest', 'username', 'show_virtual', 'real_start', 'score', 'cumtime') + list_display = ('contest', 'username', 'show_virtual', 'real_start', 'score', 'cumtime', 'tiebreaker') actions = ['recalculate_results'] actions_on_bottom = actions_on_top = True search_fields = ('contest__key', 'contest__name', 'user__user__username') @@ -292,7 +292,7 @@ class ContestParticipationAdmin(admin.ModelAdmin): def get_queryset(self, request): return super(ContestParticipationAdmin, self).get_queryset(request).only( 'contest__name', 'contest__format_name', 'contest__format_config', - 'user__user__username', 'real_start', 'score', 'cumtime', 'virtual', + 'user__user__username', 'real_start', 'score', 'cumtime', 'tiebreaker', 'virtual', ) def save_model(self, request, obj, form, change): diff --git a/judge/contest_format/__init__.py b/judge/contest_format/__init__.py index c602e4d..ee57ccf 100644 --- a/judge/contest_format/__init__.py +++ b/judge/contest_format/__init__.py @@ -1,5 +1,6 @@ from judge.contest_format.atcoder import AtCoderContestFormat from judge.contest_format.default import DefaultContestFormat from judge.contest_format.ecoo import ECOOContestFormat +from judge.contest_format.icpc import ICPCContestFormat from judge.contest_format.ioi import IOIContestFormat from judge.contest_format.registry import choices, formats diff --git a/judge/contest_format/atcoder.py b/judge/contest_format/atcoder.py index cad4e97..28b188d 100644 --- a/judge/contest_format/atcoder.py +++ b/judge/contest_format/atcoder.py @@ -91,6 +91,7 @@ class AtCoderContestFormat(DefaultContestFormat): participation.cumtime = cumtime + penalty participation.score = points + participation.tiebreaker = 0 participation.format_data = format_data participation.save() diff --git a/judge/contest_format/default.py b/judge/contest_format/default.py index a3c9c0b..532102d 100644 --- a/judge/contest_format/default.py +++ b/judge/contest_format/default.py @@ -41,6 +41,7 @@ class DefaultContestFormat(BaseContestFormat): participation.cumtime = max(cumtime, 0) participation.score = points + participation.tiebreaker = 0 participation.format_data = format_data participation.save() diff --git a/judge/contest_format/ecoo.py b/judge/contest_format/ecoo.py index ff1060a..1199826 100644 --- a/judge/contest_format/ecoo.py +++ b/judge/contest_format/ecoo.py @@ -92,6 +92,7 @@ class ECOOContestFormat(DefaultContestFormat): participation.cumtime = cumtime participation.score = points + participation.tiebreaker = 0 participation.format_data = format_data participation.save() diff --git a/judge/contest_format/ioi.py b/judge/contest_format/ioi.py index c75d707..dba337c 100644 --- a/judge/contest_format/ioi.py +++ b/judge/contest_format/ioi.py @@ -73,6 +73,7 @@ class IOIContestFormat(DefaultContestFormat): participation.cumtime = max(cumtime, 0) participation.score = points + participation.tiebreaker = 0 participation.format_data = format_data participation.save() diff --git a/judge/ratings.py b/judge/ratings.py index 8fb5ff8..6a3dac6 100644 --- a/judge/ratings.py +++ b/judge/ratings.py @@ -1,12 +1,31 @@ import math from bisect import bisect -from operator import itemgetter +from operator import attrgetter, itemgetter from django.db import connection, transaction -from django.db.models import Count +from django.db.models import Count, OuterRef, Subquery +from django.db.models.functions import Coalesce from django.utils import timezone -from judge.utils.ranker import tie_ranker + +def tie_ranker(iterable, key=attrgetter('points')): + rank = 0 + delta = 1 + last = None + buf = [] + for item in iterable: + new = key(item) + if new != last: + for _ in buf: + yield rank + (delta - 1) / 2.0 + rank += delta + delta = 0 + buf = [] + delta += 1 + buf.append(item) + last = key(item) + for _ in buf: + yield rank + (delta - 1) / 2.0 def rational_approximation(t): @@ -35,7 +54,7 @@ def WP(RA, RB, VA, VB): return (math.erf((RB - RA) / math.sqrt(2 * (VA * VA + VB * VB))) + 1) / 2.0 -def recalculate_ratings(old_rating, old_volatility, actual_rank, times_rated): +def recalculate_ratings(old_rating, old_volatility, actual_rank, times_rated, is_disqualified): # actual_rank: 1 is first place, N is last place # if there are ties, use the average of places (if places 2, 3, 4, 5 tie, use 3.5 for all of them) @@ -76,6 +95,12 @@ def recalculate_ratings(old_rating, old_volatility, actual_rank, times_rated): else: new_volatility[i] = math.sqrt(((new_rating[i] - old_rating[i]) ** 2) / Weight + (old_volatility[i] ** 2) / (Weight + 1)) + + if is_disqualified[i]: + # DQed users can manipulate TopCoder ratings to get higher volatility in order to increase their rating + # later on, prohibit this by ensuring their volatility never increases in this situation + new_volatility[i] = min(new_volatility[i], old_volatility[i]) + if abs(old_rating[i] - new_rating[i]) > Cap: if old_rating[i] < new_rating[i]: new_rating[i] = old_rating[i] + Cap @@ -96,49 +121,37 @@ def recalculate_ratings(old_rating, old_volatility, actual_rank, times_rated): def rate_contest(contest): from judge.models import Rating, Profile - cursor = connection.cursor() - cursor.execute(''' - SELECT judge_rating.user_id, judge_rating.rating, judge_rating.volatility, r.times - FROM judge_rating INNER JOIN - judge_contest ON (judge_contest.id = judge_rating.contest_id) INNER JOIN ( - SELECT judge_rating.user_id AS id, MAX(judge_contest.end_time) AS last_time, - COUNT(judge_rating.user_id) AS times - FROM judge_contestparticipation INNER JOIN - judge_rating ON (judge_rating.user_id = judge_contestparticipation.user_id) INNER JOIN - judge_contest ON (judge_contest.id = judge_rating.contest_id) - WHERE judge_contestparticipation.contest_id = %s AND judge_contest.end_time < %s AND - judge_contestparticipation.user_id NOT IN ( - SELECT profile_id FROM judge_contest_rate_exclude WHERE contest_id = %s - ) AND judge_contestparticipation.virtual = 0 - GROUP BY judge_rating.user_id - ORDER BY judge_contestparticipation.score DESC, judge_contestparticipation.cumtime ASC - ) AS r ON (judge_rating.user_id = r.id AND judge_contest.end_time = r.last_time) - ''', (contest.id, contest.end_time, contest.id)) - data = {user: (rating, volatility, times) for user, rating, volatility, times in cursor.fetchall()} - cursor.close() - - users = contest.users.order_by('is_disqualified', '-score', 'cumtime').annotate(submissions=Count('submission')) \ - .exclude(user_id__in=contest.rate_exclude.all()).filter(virtual=0, user__is_unlisted=False) \ - .values_list('id', 'user_id', 'score', 'cumtime') + rating_subquery = Rating.objects.filter(user=OuterRef('user')) + rating_sorted = rating_subquery.order_by('-contest__end_time') + users = contest.users.order_by('is_disqualified', '-score', 'cumtime', 'tiebreaker') \ + .annotate(submissions=Count('submission'), + last_rating=Coalesce(Subquery(rating_sorted.values('rating')[:1]), 1200), + volatility=Coalesce(Subquery(rating_sorted.values('volatility')[:1]), 535), + times=Coalesce(Subquery(rating_subquery.order_by().values('user_id') + .annotate(count=Count('id')).values('count')), 0)) \ + .exclude(user_id__in=contest.rate_exclude.all()) \ + .filter(virtual=0).values('id', 'user_id', 'score', 'cumtime', 'tiebreaker', 'is_disqualified', + 'last_rating', 'volatility', 'times') if not contest.rate_all: users = users.filter(submissions__gt=0) if contest.rating_floor is not None: - users = users.exclude(user__rating__lt=contest.rating_floor) + users = users.exclude(last_rating__lt=contest.rating_floor) if contest.rating_ceiling is not None: - users = users.exclude(user__rating__gt=contest.rating_ceiling) - users = list(tie_ranker(users, key=itemgetter(2, 3))) - participation_ids = [user[1][0] for user in users] - user_ids = [user[1][1] for user in users] - ranking = list(map(itemgetter(0), users)) - old_data = [data.get(user, (1200, 535, 0)) for user in user_ids] - old_rating = list(map(itemgetter(0), old_data)) - old_volatility = list(map(itemgetter(1), old_data)) - times_ranked = list(map(itemgetter(2), old_data)) - rating, volatility = recalculate_ratings(old_rating, old_volatility, ranking, times_ranked) + users = users.exclude(last_rating__gt=contest.rating_ceiling) + + users = list(users) + participation_ids = list(map(itemgetter('id'), users)) + user_ids = list(map(itemgetter('user_id'), users)) + is_disqualified = list(map(itemgetter('is_disqualified'), users)) + ranking = list(tie_ranker(users, key=itemgetter('score', 'cumtime', 'tiebreaker'))) + old_rating = list(map(itemgetter('last_rating'), users)) + old_volatility = list(map(itemgetter('volatility'), users)) + times_ranked = list(map(itemgetter('times'), users)) + rating, volatility = recalculate_ratings(old_rating, old_volatility, ranking, times_ranked, is_disqualified) now = timezone.now() - ratings = [Rating(user_id=id, contest=contest, rating=r, volatility=v, last_rated=now, participation_id=p, rank=z) - for id, p, r, v, z in zip(user_ids, participation_ids, rating, volatility, ranking)] + ratings = [Rating(user_id=i, contest=contest, rating=r, volatility=v, last_rated=now, participation_id=p, rank=z) + for i, p, r, v, z in zip(user_ids, participation_ids, rating, volatility, ranking)] cursor = connection.cursor() cursor.execute('CREATE TEMPORARY TABLE _profile_rating_update(id integer, rating integer)') cursor.executemany('INSERT INTO _profile_rating_update VALUES (%s, %s)', list(zip(user_ids, rating))) @@ -178,4 +191,4 @@ def rating_progress(rating): return 1.0 prev = 0 if not level else RATING_VALUES[level - 1] next = RATING_VALUES[level] - return (rating - prev + 0.0) / (next - prev) + return (rating - prev + 0.0) / (next - prev) \ No newline at end of file diff --git a/judge/utils/ranker.py b/judge/utils/ranker.py index b1a856a..52f0e4c 100644 --- a/judge/utils/ranker.py +++ b/judge/utils/ranker.py @@ -13,22 +13,3 @@ def ranker(iterable, key=attrgetter('points'), rank=0): yield rank, item last = key(item) - -def tie_ranker(iterable, key=attrgetter('points')): - rank = 0 - delta = 1 - last = None - buf = [] - for item in iterable: - new = key(item) - if new != last: - for i in buf: - yield rank + (delta - 1) / 2.0, i - rank += delta - delta = 0 - buf = [] - delta += 1 - buf.append(item) - last = key(item) - for i in buf: - yield rank + (delta - 1) / 2.0, i diff --git a/judge/views/api/api_v1.py b/judge/views/api/api_v1.py index c67bf34..c0c4ade 100644 --- a/judge/views/api/api_v1.py +++ b/judge/views/api/api_v1.py @@ -35,7 +35,7 @@ def api_v1_contest_detail(request, contest): can_see_rankings = contest.can_see_full_scoreboard(request.user) problems = list(contest.contest_problems.select_related('problem') .defer('problem__description').order_by('order')) - participations = (contest.users.filter(virtual=0, user__is_unlisted=False) + participations = (contest.users.filter(virtual=0) .prefetch_related('user__organizations') .annotate(username=F('user__user__username')) .order_by('-score', 'cumtime') if can_see_rankings else []) @@ -134,16 +134,15 @@ def api_v1_user_info(request, user): last_rating = profile.ratings.last() contest_history = {} - if not profile.is_unlisted: - participations = ContestParticipation.objects.filter(user=profile, virtual=0, contest__is_visible=True, - contest__is_private=False, - contest__is_organization_private=False) - for contest_key, rating, volatility in participations.values_list('contest__key', 'rating__rating', - 'rating__volatility'): - contest_history[contest_key] = { - 'rating': rating, - 'volatility': volatility, - } + participations = ContestParticipation.objects.filter(user=profile, virtual=0, contest__is_visible=True, + contest__is_private=False, + contest__is_organization_private=False) + for contest_key, rating, volatility in participations.values_list('contest__key', 'rating__rating', + 'rating__volatility'): + contest_history[contest_key] = { + 'rating': rating, + 'volatility': volatility, + } resp['contests'] = { 'current_rating': last_rating.rating if last_rating else None, diff --git a/judge/views/contests.py b/judge/views/contests.py index 9099e12..25de29b 100644 --- a/judge/views/contests.py +++ b/judge/views/contests.py @@ -591,7 +591,7 @@ class ContestStats(TitleMixin, ContestMixin, DetailView): ContestRankingProfile = namedtuple( 'ContestRankingProfile', - 'id user css_class username points cumtime organization participation ' + 'id user css_class username points cumtime tiebreaker organization participation ' 'participation_rating problem_cells result_cell', ) @@ -607,6 +607,7 @@ def make_contest_ranking_profile(contest, participation, contest_problems): username=user.username, points=participation.score, cumtime=participation.cumtime, + tiebreaker=participation.tiebreaker, organization=user.organization, participation_rating=participation.rating.rating if hasattr(participation, 'rating') else None, problem_cells=[contest.format.display_user_problem(participation, contest_problem) @@ -622,17 +623,17 @@ def base_contest_ranking_list(contest, problems, queryset): def contest_ranking_list(contest, problems): - return base_contest_ranking_list(contest, problems, contest.users.filter(virtual=0, user__is_unlisted=False) + return base_contest_ranking_list(contest, problems, contest.users.filter(virtual=0) .prefetch_related('user__organizations') .extra(select={'round_score': 'round(score, 6)'}) - .order_by('is_disqualified', '-round_score', 'cumtime')) + .order_by('is_disqualified', '-round_score', 'cumtime', 'tiebreaker')) def get_contest_ranking_list(request, contest, participation=None, ranking_list=contest_ranking_list, show_current_virtual=True, ranker=ranker): problems = list(contest.contest_problems.select_related('problem').defer('problem__description').order_by('order')) - users = ranker(ranking_list(contest, problems), key=attrgetter('points', 'cumtime')) + users = ranker(ranking_list(contest, problems), key=attrgetter('points', 'cumtime', 'tiebreaker')) if show_current_virtual: if participation is None and request.user.is_authenticated: