Skip to content

Commit

Permalink
Keep track of potential first to solves so we can update them when we…
Browse files Browse the repository at this point in the history
… get 'delayed' results.

Fixes #2281.
  • Loading branch information
Nicky Gerritsen committed Mar 13, 2024
1 parent 440cfa2 commit 9f4dcc0
Show file tree
Hide file tree
Showing 5 changed files with 272 additions and 12 deletions.
36 changes: 36 additions & 0 deletions webapp/migrations/Version20231229094359.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20231229094359 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add potential first to solve to scorecache.';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE scorecache ADD is_potential_first_to_solve TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Is this potentially the first solution to this problem?\' AFTER is_first_to_solve');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE scorecache DROP is_potential_first_to_solve');
}

public function isTransactional(): bool
{
return false;
}
}
45 changes: 45 additions & 0 deletions webapp/src/DataFixtures/Test/SampleTeamsFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php declare(strict_types=1);

namespace App\DataFixtures\Test;

use App\Entity\Team;
use App\Entity\TeamAffiliation;
use App\Entity\TeamCategory;
use Doctrine\Persistence\ObjectManager;

class SampleTeamsFixture extends AbstractTestDataFixture
{
final public const FIRST_TEAM_REFERENCE = 'team1';
final public const SECOND_TEAM_REFERENCE = 'team2';

public function load(ObjectManager $manager): void
{
$affiliation = $manager->getRepository(TeamAffiliation::class)->findOneBy(['externalid' => 'utrecht']);
$category = $manager->getRepository(TeamCategory::class)->findOneBy(['externalid' => 'participants']);

$team1 = new Team();
$team1
->setExternalid('team1')
->setIcpcid('team1')
->setLabel('team1')
->setName('Team 1')
->setAffiliation($affiliation)
->setCategory($category);

$team2 = new Team();
$team2
->setExternalid('team2')
->setIcpcid('team2')
->setLabel('team2')
->setName('Team 2')
->setAffiliation($affiliation)
->setCategory($category);

$manager->persist($team1);
$manager->persist($team2);
$manager->flush();

$this->addReference(self::FIRST_TEAM_REFERENCE, $team1);
$this->addReference(self::SECOND_TEAM_REFERENCE, $team2);
}
}
17 changes: 17 additions & 0 deletions webapp/src/Entity/ScoreCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ class ScoreCache
])]
private bool $is_first_to_solve = false;

#[ORM\Column(options: [
'comment' => 'Is this potentially the first solution to this problem?',
'default' => 0,
])]
private bool $is_potential_first_to_solve = false;

#[ORM\Id]
#[ORM\ManyToOne]
#[ORM\JoinColumn(name: 'cid', referencedColumnName: 'cid', onDelete: 'CASCADE')]
Expand Down Expand Up @@ -230,6 +236,17 @@ public function getIsFirstToSolve() : bool
return $this->is_first_to_solve;
}

public function setIsPotentialFirstToSolve(bool $isPotentialFirstToSolve): ScoreCache
{
$this->is_potential_first_to_solve = $isPotentialFirstToSolve;
return $this;
}

public function getIsPotentialFirstToSolve() : bool
{
return $this->is_potential_first_to_solve;
}

public function setContest(?Contest $contest = null): ScoreCache
{
$this->contest = $contest;
Expand Down
69 changes: 57 additions & 12 deletions webapp/src/Service/ScoreboardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ public function calculateScoreRow(
Contest $contest,
Team $team,
Problem $problem,
bool $updateRankCache = true
bool $updateRankCache = true,
bool $updatePotentialFirstToSolves = true
): void {
$this->logger->debug(
"ScoreboardService::calculateScoreRow '%d' '%d' '%d'",
Expand Down Expand Up @@ -417,6 +418,7 @@ public function calculateScoreRow(
// See if this submission was the first to solve this problem.
// Only relevant if it was correct in the first place.
$firstToSolve = false;
$potentialFirstToSolve = false;
if ($correctJury) {
$params = [
'cid' => $contest->getCid(),
Expand All @@ -437,32 +439,49 @@ public function calculateScoreRow(
// - or already judged to be correct (if it is judged but not correct,
// it is not a first to solve)
// - or the submission is still queued for judgement (judgehost is NULL).
$verificationRequiredExtra = $verificationRequired ? 'OR j.verified = 0' : '';
// If there are no valid correct submissions submitted earlier but there
// are submissions awaiting judgement, we are potentially the first to solve.
// We need to keep track of this since we later need to set the actual first to solve.
$verificationRequiredExtra = ($verificationRequired && !$useExternalJudgements) ? 'OR j.verified = 0' : '';
if ($useExternalJudgements) {
$firstToSolve = 0 == $this->em->getConnection()->fetchOne('
$baseQuery = '
SELECT count(*) FROM submission s
LEFT JOIN external_judgement ej USING (submitid)
LEFT JOIN external_judgement ej2 ON ej2.submitid = s.submitid AND ej2.starttime > ej.starttime
LEFT JOIN team t USING(teamid)
LEFT JOIN team_category tc USING (categoryid)
WHERE s.valid = 1 AND
(ej.result IS NULL OR ej.result = :correctResult '.
$verificationRequiredExtra.') AND
s.cid = :cid AND s.probid = :probid AND
tc.sortorder = :teamSortOrder AND
round(s.submittime,4) < :submitTime', $params);
round(s.submittime,4) < :submitTime';
$judgingTable = 'ej';
} else {
$firstToSolve = 0 == $this->em->getConnection()->fetchOne('
$baseQuery = '
SELECT count(*) FROM submission s
LEFT JOIN judging j ON (s.submitid=j.submitid AND j.valid=1)
LEFT JOIN team t USING (teamid)
LEFT JOIN team_category tc USING (categoryid)
WHERE s.valid = 1 AND
(j.judgingid IS NULL OR j.result IS NULL OR j.result = :correctResult '.
$verificationRequiredExtra.') AND
s.cid = :cid AND s.probid = :probid AND
tc.sortorder = :teamSortOrder AND
round(s.submittime,4) < :submitTime', $params);
round(s.submittime,4) < :submitTime';
$judgingTable = 'j';
}

$numEarlierCorrect = $this->em->getConnection()->fetchOne(
"$baseQuery AND $judgingTable.result = :correctResult",
$params);
$numEarlierPending = $this->em->getConnection()->fetchOne(
"$baseQuery AND ($judgingTable.result IS NULL $verificationRequiredExtra)",
$params);

if ($numEarlierCorrect == 0) {
// This is either a first to solve or potential first to solve
if ($numEarlierPending == 0) {
$firstToSolve = true;
} else {
$potentialFirstToSolve = true;
}
}
}

Expand All @@ -482,13 +501,14 @@ public function calculateScoreRow(
'runtimePublic' => $runtimePubl === PHP_INT_MAX ? 0 : $runtimePubl,
'isCorrectPublic' => (int)$correctPubl,
'isFirstToSolve' => (int)$firstToSolve,
'isPotentialFirstToSolve' => (int)$potentialFirstToSolve,
];
$this->em->getConnection()->executeQuery('REPLACE INTO scorecache
(cid, teamid, probid,
submissions_restricted, pending_restricted, solvetime_restricted, runtime_restricted, is_correct_restricted,
submissions_public, pending_public, solvetime_public, runtime_public, is_correct_public, is_first_to_solve)
submissions_public, pending_public, solvetime_public, runtime_public, is_correct_public, is_first_to_solve, is_potential_first_to_solve)
VALUES (:cid, :teamid, :probid, :submissionsRestricted, :pendingRestricted, :solvetimeRestricted, :runtimeRestricted, :isCorrectRestricted,
:submissionsPublic, :pendingPublic, :solvetimePublic, :runtimePublic, :isCorrectPublic, :isFirstToSolve)', $params);
:submissionsPublic, :pendingPublic, :solvetimePublic, :runtimePublic, :isCorrectPublic, :isFirstToSolve, :isPotentialFirstToSolve)', $params);

if ($this->em->getConnection()->fetchOne('SELECT RELEASE_LOCK(:lock)',
['lock' => $lockString]) != 1) {
Expand All @@ -499,6 +519,31 @@ public function calculateScoreRow(
if ($updateRankCache && ($correctJury || $correctPubl)) {
$this->updateRankCache($contest, $team);
}

// If we did not have a first to solve, we need to check if we have any
// potential first to solve that are now the first to solve.
// We only do this if there are no pending submissions for this problem
// for this team and if this submission is not a potential first to solve itself.
// We also do this only once, to not have infinite loops if we have multiple
// potential first to solves.
if ($updatePotentialFirstToSolves && $pendingJury === 0 && !$potentialFirstToSolve) {
/** @var ScoreCache[] $potentialFirstToSolves */
$potentialFirstToSolves = $this->em->createQueryBuilder()
->from(ScoreCache::class, 's')
->join('s.team', 't')
->select('s', 't')
->andWhere('s.contest = :contest')
->andWhere('s.problem = :problem')
->andWhere('s.is_potential_first_to_solve = 1')
->setParameter('contest', $contest)
->setParameter('problem', $problem)
->getQuery()
->getResult();

foreach ($potentialFirstToSolves as $row) {
$this->calculateScoreRow($contest, $row->getTeam(), $problem, $updateRankCache, false);
}
}
}

/**
Expand Down
117 changes: 117 additions & 0 deletions webapp/tests/Unit/Service/ScoreboardServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\DataFixtures\Test\SampleTeamsFixture;
use App\Entity\Contest;
use App\Entity\ContestProblem;
use App\Entity\Judging;
use App\Entity\Language;
use App\Entity\ScoreCache;
use App\Entity\Submission;
use App\Entity\Team;
use App\Service\ScoreboardService;
use App\Tests\Unit\BaseTestCase;
use App\Utils\Utils;
use Doctrine\ORM\EntityManagerInterface;

class ScoreboardServiceTest extends BaseTestCase
{
/**
* Test that a delayed submission result still results in a correct first to solve
*/
public function testFirstToSolveDelayed(): void
{
$em = static::getContainer()->get(EntityManagerInterface::class);
$this->loadFixture(SampleTeamsFixture::class);
$team1 = $this->fixtureExecutor->getReferenceRepository()->getReference(SampleTeamsFixture::FIRST_TEAM_REFERENCE);
$team2 = $this->fixtureExecutor->getReferenceRepository()->getReference(SampleTeamsFixture::SECOND_TEAM_REFERENCE);
$contest = $em->getRepository(Contest::class)->findOneBy(['shortname' => 'demo']);
$contestProblem = $contest->getProblems()->first();
$problem = $contestProblem->getProblem();

$scoreboardService = static::getContainer()->get(ScoreboardService::class);

// Create a submission for both team 1 and team 2.
// The submission for team 1 will be pending, while the submission
// for team 2 will be correct.
$submission1 = $this->createSubmission($em, $team1, $contestProblem, $contest, null);
$submission2 = $this->createSubmission($em, $team2, $contestProblem, $contest, 'correct');
$em->flush();

$scoreboardService->calculateScoreRow($contest, $team1, $problem);
$scoreboardService->calculateScoreRow($contest, $team2, $problem);

/** @var ScoreCache $scoreCacheTeam1 */
$scoreCacheTeam1 = $em->getRepository(ScoreCache::class)->findOneBy([
'contest' => $contest,
'team' => $team1,
'problem' => $problem,
]);
/** @var ScoreCache $scoreCacheTeam2 */
$scoreCacheTeam2 = $em->getRepository(ScoreCache::class)->findOneBy([
'contest' => $contest,
'team' => $team2,
'problem' => $problem,
]);

static::assertFalse($scoreCacheTeam1->getIsFirstToSolve());
static::assertFalse($scoreCacheTeam2->getIsFirstToSolve());

// Now update the submission for team 1 to be wrong
$submission1->getJudgings()->first()->setResult('wrong');
$em->flush();

$scoreboardService->calculateScoreRow($contest, $team1, $problem);

// We need to clear the entity manager to make sure we get the updated score caches.
$em->clear();

/** @var ScoreCache $scoreCacheTeam1 */
$scoreCacheTeam1 = $em->getRepository(ScoreCache::class)->findOneBy([
'contest' => $contest,
'team' => $team1,
'problem' => $problem,
]);
/** @var ScoreCache $scoreCacheTeam2 */
$scoreCacheTeam2 = $em->getRepository(ScoreCache::class)->findOneBy([
'contest' => $contest,
'team' => $team2,
'problem' => $problem,
]);

static::assertFalse($scoreCacheTeam1->getIsFirstToSolve());
static::assertTrue($scoreCacheTeam2->getIsFirstToSolve());
}

protected function createSubmission(
EntityManagerInterface $em,
Team $team,
ContestProblem $problem,
Contest $contest,
?string $result
): Submission {
$submission = new Submission();
$submission
->setTeam($team)
->setProblem($problem->getProblem())
->setContest($contest)
->setContestProblem($problem)
->setLanguage($em->getRepository(Language::class)->findOneBy(['name' => 'C++']))
->setValid(true)
->setSubmittime(Utils::now())
->addJudging(
$judging = (new Judging())
->setValid(true)
->setStarttime(Utils::now())
->setEndtime(Utils::now())
->setSubmission($submission)
->setResult($result)
);

$em->persist($submission);
$em->persist($judging);

return $submission;
}
}

0 comments on commit 9f4dcc0

Please sign in to comment.