Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add standardised scoring for judged contests #12014

Open
wants to merge 28 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b12526f
add standard deviation calculation helper
notbakaneko Mar 13, 2025
f9aae4f
add standardized score to score
notbakaneko Mar 13, 2025
f92871e
render standardised score
notbakaneko Mar 14, 2025
5934746
update to using transformer class explicitly
notbakaneko Mar 14, 2025
e820d60
split into functions
notbakaneko Mar 18, 2025
6a34b72
move to scope
notbakaneko Mar 18, 2025
81e441c
also move to scope
notbakaneko Mar 18, 2025
6020767
pre-calculate standardised scores
notbakaneko Mar 18, 2025
6fcaa0c
pass flag into scope instead of Contest
notbakaneko Mar 18, 2025
49eae94
include and order by calculated standardised score
notbakaneko Mar 18, 2025
7ab824b
can just use withSum
notbakaneko Mar 18, 2025
341aa29
show on same line
notbakaneko Mar 21, 2025
7affd2f
null category makes no sense
notbakaneko Mar 21, 2025
079a75c
add a gap
notbakaneko Mar 21, 2025
efa47b1
unneeded
notbakaneko Mar 21, 2025
cc9643e
show total standardised score
notbakaneko Mar 21, 2025
a89a47f
add migrations
notbakaneko Mar 21, 2025
a489e9f
add command to calculate scores and stddev
notbakaneko Mar 21, 2025
691584b
handle case if std dev is some how 0
notbakaneko Mar 21, 2025
44ef869
simpler query (also applies soft deletes global scope)
notbakaneko Mar 25, 2025
aae1c60
doesn't need to be qualified?
notbakaneko Mar 25, 2025
748ca54
lint
notbakaneko Mar 25, 2025
a0d074f
nice migration name
notbakaneko Mar 26, 2025
f6b9b45
just string
notbakaneko Mar 26, 2025
c873dce
use formatNumber
notbakaneko Mar 26, 2025
bc6e675
remove unused leftover from testing
notbakaneko Mar 26, 2025
e6431a5
fix copypasta description
notbakaneko Mar 26, 2025
21631a4
not really useful
notbakaneko Mar 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/Console/Commands/ContestCalculateScores.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
// See the LICENCE file in the repository root for full licence text.

namespace App\Console\Commands;

use App\Models\Contest;
use Illuminate\Console\Command;

class ContestCalculateScores extends Command
{
protected $signature = 'contest:calculate-scores {contestId}';

protected $description = 'Cleans up expired chat keep alives.';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice description


public function handle()
{
$contestId = get_int($this->argument('contestId'));
if ($contestId === null) {
$this->error('Contest id is required.');
return static::INVALID;
}

$contest = Contest::findOrFail($contestId);
if (!$contest->isScoreStandardised()) {
$this->error('Contest does not use standardised scoring.');
return static::FAILURE;
}

$contest->calculateScores();
}
}
8 changes: 5 additions & 3 deletions app/Http/Controllers/ContestEntriesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Models\Contest;
use App\Models\ContestEntry;
use App\Models\UserContestEntry;
use App\Transformers\ContestEntryTransformer;
use App\Transformers\ContestTransformer;
use Auth;
use Ds\Set;
Expand Down Expand Up @@ -45,15 +46,16 @@ public function judgeResults($id)
],
);

$entryJson = json_item($entry, 'ContestEntry', [
$entryJson = json_item($entry, new ContestEntryTransformer(), [
'judge_votes.scores',
'judge_votes.total_score',
'judge_votes.total_score_std',
'judge_votes.user',
'results',
'user',
]);

$entriesJson = json_collection($entry->contest->entries, 'ContestEntry');
$entriesJson = json_collection($entry->contest->entries, new ContestEntryTransformer());

return ext_view('contest_entries.judge-results', [
'contestJson' => $contestJson,
Expand Down Expand Up @@ -100,7 +102,7 @@ public function judgeVote($id)

$updatedEntry = $entry->refresh()->load('judgeVotes.scores');

return json_item($updatedEntry, 'ContestEntry', ['current_user_judge_vote.scores']);
return json_item($updatedEntry, new ContestEntryTransformer(), ['current_user_judge_vote.scores']);
}

public function vote($id)
Expand Down
87 changes: 35 additions & 52 deletions app/Models/Contest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
* @property-read Collection<ContestJudge> $contestJudges
* @property \Carbon\Carbon|null $created_at
* @property string $description_enter
* @property string|null $description_voting
Expand All @@ -28,7 +29,7 @@
* @property string $header_url
* @property int $id
* @property mixed $link_icon
* @property-read Collection<ContestJudge> $judges
* @property-read Collection<User> $judges
* @property int $max_entries
* @property int $max_votes
* @property string $name
Expand Down Expand Up @@ -57,6 +58,11 @@ class Contest extends Model
'voting_starts_at' => 'datetime',
];

public function contestJudges(): HasMany
{
return $this->HasMany(ContestJudge::class);
}

public function entries()
{
return $this->hasMany(ContestEntry::class);
Expand Down Expand Up @@ -115,6 +121,14 @@ public function assertVoteRequirement(?User $user): void
}
}

public function calculateScores(): void
{
$this->contestJudges->each->calculateStdDev();

$judgeVotes = ContestJudgeVote::whereHas('entry', fn ($q) => $q->whereHas('contest', fn ($qq) => $qq->where('contest_id', $this->getKey())))->get();
$judgeVotes->each->calculateScore();
}

public function isBestOf(): bool
{
return isset($this->getExtraOptions()['best_of']);
Expand All @@ -137,6 +151,11 @@ public function isJudgingActive(): bool
return $this->isJudged() && $this->isVotingStarted() && !$this->show_votes;
}

public function isScoreStandardised(): bool
{
return $this->getExtraOptions()['is_score_standardised'] ?? false;
}

public function isSubmittedBeatmaps(): bool
{
return $this->isBestOf() || ($this->getExtraOptions()['submitted_beatmaps'] ?? false);
Expand Down Expand Up @@ -271,64 +290,28 @@ public function vote(User $user, ContestEntry $entry)
}
}

public function entriesByType($user = null, array $preloads = [])
public function entriesByType(?User $user, array $preloads = [])
{
$entries = $this->entries()->with(['contest', ...$preloads]);
$query = $this->entries()->with(['contest', ...$preloads]);

if ($this->show_votes) {
return Cache::remember("contest_entries_with_votes_{$this->id}", 300, function () use ($entries) {
$orderValue = 'votes_count';

if ($this->isBestOf()) {
$entries = $entries
->selectRaw('*')
->selectRaw('(SELECT FLOOR(SUM(`weight`)) FROM `contest_votes` WHERE `contest_entries`.`id` = `contest_votes`.`contest_entry_id`) AS votes_count')
->limit(50); // best of contests tend to have a _lot_ of entries...
} else if ($this->isJudged()) {
$entries = $entries->withSum('scores', 'value');
$orderValue = 'scores_sum_value';
} else {
$entries = $entries->withCount('votes');
}

return $entries->orderBy($orderValue, 'desc')->get();
});
} else {
if ($this->isBestOf()) {
if ($user === null) {
return [];
}
$options = $this->getExtraOptions();

// Only return contest entries that a user has actually played
return $entries
->whereIn('entry_url', function ($query) use ($user) {
$options = $this->getExtraOptions()['best_of'];
$ruleset = $options['mode'] ?? 'osu';
$query->select('beatmapset_id')
->from('osu_beatmaps')
->where('osu_beatmaps.playmode', Beatmap::MODES[$ruleset])
->whereIn('beatmap_id', function ($query) use ($user) {
$query->select('beatmap_id')
->from('osu_user_beatmap_playcount')
->where('user_id', '=', $user->user_id);
});

if ($ruleset === 'mania' && isset($options['variant'])) {
if ($options['variant'] === 'nk') {
$query->whereNotIn('osu_beatmaps.diff_size', [4, 7]);
} else {
$keys = match ($options['variant']) {
'4k' => 4,
'7k' => 7,
};
$query->where('osu_beatmaps.diff_size', $keys);
}
}
})->get();
return Cache::remember(
"contest_entries_with_votes_{$this->id}",
300,
fn () => $query->with(['contest', ...$preloads])->withScore($options)->get()
);
} elseif ($this->isBestOf()) {
if ($user === null) {
return [];
}

$options = $this->getExtraOptions()['best_of'];
$query->forBestOf($user, $options['mode'] ?? 'osu', $options['variant'] ?? null);
}

return $entries->get();
return $query->get();
}

public function defaultJson($user = null)
Expand Down
55 changes: 55 additions & 0 deletions app/Models/ContestEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

namespace App\Models;

use Illuminate\Contracts\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
Expand Down Expand Up @@ -52,6 +54,59 @@ public function votes()
return $this->hasMany(ContestVote::class);
}

public function scopeForBestOf(Builder $query, User $user, string $ruleset, ?string $variant = null): Builder
{
$query->whereIn('entry_url', function (QueryBuilder $beatmapsetQuery) use ($ruleset, $user, $variant) {
$beatmapsetQuery->select('beatmapset_id')
->from('osu_beatmaps')
->where('osu_beatmaps.playmode', Beatmap::MODES[$ruleset])
->whereIn('beatmap_id', function (QueryBuilder $beatmapQuery) use ($user) {
$beatmapQuery->select('beatmap_id')
->from('osu_user_beatmap_playcount')
->where('user_id', $user->getKey());
});

if ($ruleset === 'mania' && $variant !== null) {
if ($variant === 'nk') {
$beatmapsetQuery->whereNotIn('osu_beatmaps.diff_size', [4, 7]);
} else {
$keys = match ($variant) {
'4k' => 4,
'7k' => 7,
};
$beatmapsetQuery->where('osu_beatmaps.diff_size', $keys);
}
}
});

return $query;
}

public function scopeWithScore(Builder $query, array $options): Builder
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this accept Contest model instead? So the isset($options...) and all other craps don't need to be reimplemented (apparently that's how it's initially done? but then changed a few times until this one but there's no clear reason in the commit messages)

{
$orderValue = 'votes_count';

if (isset($options['best_of'])) {
$query
->selectRaw('*')
->selectRaw('(SELECT FLOOR(SUM(`weight`)) FROM `contest_votes` WHERE `contest_entries`.`id` = `contest_votes`.`contest_entry_id`) AS votes_count')
->limit(50); // best of contests tend to have a _lot_ of entries...
} else if ($options['judged'] ?? false) {
$query->withSum('scores', 'value');

if ($options['is_score_standardised'] ?? false) {
$query->withSum('judgeVotes as total_score_std', 'total_score_std');
$orderValue = 'total_score_std';
} else {
$orderValue = 'scores_sum_value';
}
} else {
$query->withCount('votes');
}

return $query->orderBy($orderValue, 'desc');
}

public function thumbnail(): ?string
{
if (!$this->contest->hasThumbnails()) {
Expand Down
23 changes: 23 additions & 0 deletions app/Models/ContestJudge.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
* @property-read Contest $contest
* @property int $contest_id
* @property ?float $mean
* @property ?float $std_dev
* @property-read User $user
* @property int $user_id
*/
Expand All @@ -27,4 +30,24 @@ public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}

public function calculateStdDev(): void
{
[$stdDev, $mean] = $this->stdDev();

$this->update(['mean' => $mean, 'std_dev' => $stdDev]);
}

public function stdDev(): array
{
// TODO: treat missing scores as 0?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all judges are supposed to judge all entries and they're poked if they haven't
default of 0 could make sense if it's to indicate that something is wrong, though ideally we'd know by counting the number of votes

$entryScores = ContestJudgeScore::scoresByEntry()
->whereHas(
'vote',
fn (Builder $q) => $q->where('user_id', $this->user_id)
->whereHas('entry', fn (Builder $qq) => $qq->where('contest_id', $this->contest_id))
Comment on lines +45 to +48
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
->whereHas(
'vote',
fn (Builder $q) => $q->where('user_id', $this->user_id)
->whereHas('entry', fn (Builder $qq) => $qq->where('contest_id', $this->contest_id))
// maybe adjust the scope to accept ids instead
->scoresFrom($this->user, Contest::find($this->contest_id)

)->pluck('total');

return std_dev($entryScores->toArray());
}
}
19 changes: 19 additions & 0 deletions app/Models/ContestJudgeScore.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
Expand All @@ -30,4 +31,22 @@ public function vote(): BelongsTo
{
return $this->belongsTo(ContestJudgeVote::class, 'contest_judge_vote_id');
}

public function scopeScoresFrom(Builder $query, User $user, Contest $contest)
{
return $query->whereIn(
'contest_judge_vote_id',
ContestJudgeVote
::where('user_id', $user->getKey())
->whereIn('contest_entry_id', $contest->entries()->select('id'))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more "laravel" (also this is what you had somewhere else)

Suggested change
->whereIn('contest_entry_id', $contest->entries()->select('id'))
->whereHas('entry', fn ($q) => $q->where('contest_id', $contest->getKey()))

->select('id')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a weird indent

);
}

public function scopeScoresByEntry(Builder $query)
{
return $query->groupBy('contest_judge_vote_id')
->select('contest_judge_vote_id')
->addSelect(\DB::raw('SUM(value) as total'));
}
}
10 changes: 10 additions & 0 deletions app/Models/ContestJudgeVote.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* @property \Carbon\Carbon|null $created_at
* @property-read ContestEntry $entry
* @property int $id
* @property float $total_score_std
* @property \Carbon\Carbon|null $updated_at
* @property-read User $user
* @property int $user_id
Expand All @@ -43,4 +44,13 @@ public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}

public function calculateScore(): void
{
$judge = ContestJudge::where(['contest_id' => $this->entry->contest_id, 'user_id' => $this->user_id])->first();

if ($judge->std_dev !== null) {
$this->update(['total_score_std' => $judge->std_dev === 0.0 ? 0 : ($this->totalScore() - $judge->mean) / $judge->std_dev]);
}
}
}
6 changes: 4 additions & 2 deletions app/Transformers/ContestEntryTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,15 @@ public function includeJudgeVotes(ContestEntry $entry): Collection

public function includeResults(ContestEntry $entry)
{
$votes = $entry->contest->isJudged()
$judged = $entry->contest->isJudged();
$votes = $judged
? $entry->scores_sum_value
: $entry->votes_count;

return $this->primitive([
'actual_name' => $entry->name,
'votes' => (int) $votes,
'score_std' => $judged ? $entry->total_score_std : null,
'votes' => (int) $votes, // TODO: change to score or something else, or even better stop overloading the results value.
]);
}

Expand Down
Loading