diff --git a/judge/judgedaemon.main.php b/judge/judgedaemon.main.php index 314a132f73..8b165c91af 100644 --- a/judge/judgedaemon.main.php +++ b/judge/judgedaemon.main.php @@ -1461,6 +1461,7 @@ function judge(array $judgeTask): bool } } + $compare_args = $compare_config['compare_args']; $test_run_cmd = LIBJUDGEDIR . "/testcase_run.sh $cpuset_opt " . implode(' ', array_map('dj_escapeshellarg', [ $input, @@ -1469,7 +1470,7 @@ function judge(array $judgeTask): bool $passdir, $run_runpath, $compare_runpath, - $compare_config['compare_args'] + $compare_args, ])); system($test_run_cmd, $retval); @@ -1523,11 +1524,16 @@ function judge(array $judgeTask): bool if (file_exists($passdir . '/feedback/teammessage.txt')) { $new_judging_run['team_message'] = rest_encode_file($passdir . '/feedback/teammessage.txt', $output_storage_limit); } + $score = ""; + if ($result === 'correct' && file_exists($passdir . '/feedback/score.txt')) { + $new_judging_run['score'] = rest_encode_file($passdir . '/feedback/score.txt'); + $score = ", score: " . trim(dj_file_get_contents($passdir . '/feedback/score.txt')); + } if ($passLimit > 1) { $walltime = $metadata['wall-time'] ?? '?'; logmsg(LOG_INFO, ' ' . ($result === 'correct' ? " \033[0;32m✔\033[0m" : " \033[1;31m✗\033[0m") - . ' ...done in ' . $walltime . 's (CPU: ' . $runtime . 's), result: ' . $result); + . ' ...done in ' . $walltime . 's (CPU: ' . $runtime . 's), result: ' . $result . $score); } if ($result !== 'correct') { @@ -1575,7 +1581,7 @@ function judge(array $judgeTask): bool if ($passLimit == 1) { $walltime = $metadata['wall-time'] ?? '?'; logmsg(LOG_INFO, ' ' . ($result === 'correct' ? " \033[0;32m✔\033[0m" : " \033[1;31m✗\033[0m") - . ' ...done in ' . $walltime . 's (CPU: ' . $runtime . 's), result: ' . $result); + . ' ...done in ' . $walltime . 's (CPU: ' . $runtime . 's), result: ' . $result . $score); } // done! diff --git a/webapp/migrations/Version20250704181912.php b/webapp/migrations/Version20250704181912.php new file mode 100644 index 0000000000..0c012d73ee --- /dev/null +++ b/webapp/migrations/Version20250704181912.php @@ -0,0 +1,66 @@ +addSql('CREATE TABLE testcase_group (testcase_group_id INT UNSIGNED AUTO_INCREMENT NOT NULL COMMENT \'Testcase group ID\', name VARCHAR(255) NOT NULL COMMENT \'Name of the testcase group\', accept_score VARCHAR(255) DEFAULT NULL COMMENT \'Score if this group is accepted\', range_lower_bound VARCHAR(255) DEFAULT NULL COMMENT \'Lower bound of the score range\', range_upper_bound VARCHAR(255) DEFAULT NULL COMMENT \'Upper bound of the score range\', aggregation_type VARCHAR(255) DEFAULT \'sum\' NOT NULL COMMENT \'How to aggregate scores for this group\', ignore_sample TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Ignore the sample testcases when aggregating scores\', PRIMARY KEY(testcase_group_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'Testcase group metadata\' '); + $this->addSql('ALTER TABLE testcase ADD testcase_group_id INT UNSIGNED DEFAULT NULL COMMENT \'Testcase group ID\''); + $this->addSql('ALTER TABLE testcase ADD CONSTRAINT FK_4C1E5C391FF421A3 FOREIGN KEY (testcase_group_id) REFERENCES testcase_group (testcase_group_id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_4C1E5C391FF421A3 ON testcase (testcase_group_id)'); + $this->addSql('ALTER TABLE judging_run ADD score NUMERIC(32, 9) DEFAULT \'0.000000000\' NOT NULL COMMENT \'Optional score for this run, e.g. for partial scoring\''); + $this->addSql('ALTER TABLE judging ADD score NUMERIC(32, 9) DEFAULT \'0.000000000\' NOT NULL COMMENT \'Optional score for this run, e.g. for partial scoring\''); + $this->addSql('ALTER TABLE testcase_group ADD parent_id INT UNSIGNED DEFAULT NULL COMMENT \'Testcase group ID\''); + $this->addSql('ALTER TABLE testcase_group ADD CONSTRAINT FK_F02888FE727ACA70 FOREIGN KEY (parent_id) REFERENCES testcase_group (testcase_group_id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_F02888FE727ACA70 ON testcase_group (parent_id)'); + $this->addSql('ALTER TABLE problem ADD parent_testcase_group_id INT UNSIGNED DEFAULT NULL COMMENT \'Testcase group ID\''); + $this->addSql('ALTER TABLE problem ADD CONSTRAINT FK_D7E7CCC8A090DCC7 FOREIGN KEY (parent_testcase_group_id) REFERENCES testcase_group (testcase_group_id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_D7E7CCC8A090DCC7 ON problem (parent_testcase_group_id)'); + $this->addSql('ALTER TABLE testcase_group ADD output_validator_flags VARCHAR(255) DEFAULT NULL COMMENT \'Flags for output validation\''); + $this->addSql('ALTER TABLE testcase_group ADD on_reject_continue TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Continue on reject\''); + $this->addSql('ALTER TABLE scorecache ADD score_public NUMERIC(32, 9) DEFAULT \'0.000000000\' NOT NULL COMMENT \'Optional score for this run, e.g. for partial scoring\', ADD score_restricted NUMERIC(32, 9) DEFAULT \'0.000000000\' NOT NULL COMMENT \'Optional score for this run, e.g. for partial scoring (for restricted audience)\''); + $this->addSql('ALTER TABLE rankcache ADD score_public NUMERIC(32, 9) DEFAULT \'0.000000000\' NOT NULL COMMENT \'Optional score for this run, e.g. for partial scoring\', ADD score_restricted NUMERIC(32, 9) DEFAULT \'0.000000000\' NOT NULL COMMENT \'Optional score for this run, e.g. for partial scoring (for restricted audience)\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE rankcache DROP score_public, DROP score_restricted'); + $this->addSql('ALTER TABLE scorecache DROP score_public, DROP score_restricted'); + $this->addSql('ALTER TABLE testcase_group DROP on_reject_continue'); + $this->addSql('ALTER TABLE testcase_group DROP output_validator_flags'); + $this->addSql('ALTER TABLE problem DROP FOREIGN KEY FK_D7E7CCC8A090DCC7'); + $this->addSql('DROP INDEX IDX_D7E7CCC8A090DCC7 ON problem'); + $this->addSql('ALTER TABLE problem DROP parent_testcase_group_id'); + $this->addSql('ALTER TABLE testcase_group DROP FOREIGN KEY FK_F02888FE727ACA70'); + $this->addSql('DROP INDEX IDX_F02888FE727ACA70 ON testcase_group'); + $this->addSql('ALTER TABLE testcase_group DROP parent_id'); + $this->addSql('ALTER TABLE judging DROP score'); + $this->addSql('ALTER TABLE judging_run DROP score'); + $this->addSql('ALTER TABLE testcase DROP FOREIGN KEY FK_4C1E5C391FF421A3'); + $this->addSql('DROP TABLE testcase_group'); + $this->addSql('DROP INDEX IDX_4C1E5C391FF421A3 ON testcase'); + $this->addSql('ALTER TABLE testcase DROP testcase_group_id'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/migrations/Version20250804192300.php b/webapp/migrations/Version20250804192300.php new file mode 100644 index 0000000000..7bef6f9cee --- /dev/null +++ b/webapp/migrations/Version20250804192300.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE external_judgement ADD score NUMERIC(32, 9) DEFAULT \'0.000000000\' NOT NULL COMMENT \'Optional score for this run, e.g. for partial scoring\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE external_judgement DROP score'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/migrations/Version20250804194436.php b/webapp/migrations/Version20250804194436.php new file mode 100644 index 0000000000..1bae1338bf --- /dev/null +++ b/webapp/migrations/Version20250804194436.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE external_run ADD score NUMERIC(32, 9) DEFAULT \'0.000000000\' NOT NULL COMMENT \'Optional score for this run, e.g. for partial scoring\''); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE external_run DROP score'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/Controller/API/JudgehostController.php b/webapp/src/Controller/API/JudgehostController.php index 5f9523ff4e..1d5429a131 100644 --- a/webapp/src/Controller/API/JudgehostController.php +++ b/webapp/src/Controller/API/JudgehostController.php @@ -18,6 +18,7 @@ use App\Entity\Rejudging; use App\Entity\Submission; use App\Entity\SubmissionFile; +use App\Entity\TestcaseAggregationType; use App\Entity\TestcaseContent; use App\Entity\Version; use App\Service\BalloonService; @@ -652,7 +653,8 @@ public function addJudgingRunAction( $teamMessage = $request->request->get('team_message'); $metadata = $request->request->get('metadata'); $testcasedir = $request->request->get('testcasedir'); - $compareMeta = $request->request->get('compare_metadata'); + $compareMeta = $request->request->get('compare_metadata'); + $score = $request->request->get('score'); $judgehost = $this->em->getRepository(Judgehost::class)->findOneBy(['hostname' => $hostname]); if (!$judgehost) { @@ -660,7 +662,7 @@ public function addJudgingRunAction( } $hasFinalResult = $this->addSingleJudgingRun($judgeTaskId, $hostname, $runResult, $runTime, - $outputSystem, $outputError, $outputDiff, $outputRun, $teamMessage, $metadata, $testcasedir, $compareMeta); + $outputSystem, $outputError, $outputDiff, $outputRun, $teamMessage, $metadata, $testcasedir, $compareMeta, $score); $judgehost = $this->em->getRepository(Judgehost::class)->findOneBy(['hostname' => $hostname]); $judgehost->setPolltime(Utils::now()); $this->em->flush(); @@ -932,6 +934,7 @@ private function addSingleJudgingRun( string $metadata, ?string $testcasedir, ?string $compareMeta, + ?string $score = null ): bool { $resultsRemap = $this->config->get('results_remap'); $resultsPrio = $this->config->get('results_prio'); @@ -953,7 +956,8 @@ private function addSingleJudgingRun( $teamMessage, $metadata, $testcasedir, - $compareMeta + $compareMeta, + $score ) { $judgingRun = $this->em->getRepository(JudgingRun::class)->findOneBy( ['judgetaskid' => $judgeTaskId]); @@ -983,6 +987,10 @@ private function addSingleJudgingRun( $judgingRunOutput->setTeamMessage(base64_decode($teamMessage)); } + if ($score) { + $judgingRun->setScore(base64_decode($score)); + } + $judging = $judgingRun->getJudging(); $this->maybeUpdateActiveJudging($judging); $this->em->flush(); @@ -1019,15 +1027,30 @@ private function addSingleJudgingRun( $oldResult = $judging->getResult(); $lazyEval = DOMJudgeService::EVAL_LAZY; - if (($result = SubmissionService::getFinalResult($runresults, $resultsPrio)) !== null) { + $problem = $judging->getSubmission()->getProblem(); + if ($problem->isScoringProblem()) { + $parentGroup = $problem->getParentTestcaseGroup(); + $scoreAndResult = SubmissionService::maybeSetScoringResult( + $parentGroup, + $judging + ); + $score = $scoreAndResult[0]; + $result = $scoreAndResult[1]; + } else { + $result = SubmissionService::getFinalResult($runresults, $resultsPrio); + } + if ($result !== null) { // Lookup global lazy evaluation of results setting and possible problem specific override. - $lazyEval = $this->config->get('lazy_eval_results'); + $lazyEval = $this->config->get('lazy_eval_results'); $problemLazy = $judging->getSubmission()->getContestProblem()->getLazyEvalResults(); if ($problemLazy !== DOMJudgeService::EVAL_DEFAULT) { $lazyEval = $problemLazy; } $judging->setResult($result); + if ($problem->isScoringProblem()) { + $judging->setScore($score); + } $hasNullResults = false; foreach ($runresults as $runresult) { @@ -1100,16 +1123,16 @@ private function addSingleJudgingRun( } $submission = $judging->getSubmission(); - $contest = $submission->getContest(); - $team = $submission->getTeam(); - $problem = $submission->getProblem(); + $contest = $submission->getContest(); + $team = $submission->getTeam(); + $problem = $submission->getProblem(); $this->scoreboardService->calculateScoreRow($contest, $team, $problem); // We call alert here before possible validation. Note that // this means that these alert messages should be treated as // confidential information. $msg = sprintf("submission %s, judging %s: %s", - $submission->getSubmitid(), $judging->getJudgingid(), $result); + $submission->getSubmitid(), $judging->getJudgingid(), $result); $this->dj->alert($result === 'correct' ? 'accept' : 'reject', $msg); // Potentially send a balloon, i.e. if no verification required (case of verification required is diff --git a/webapp/src/Controller/BaseController.php b/webapp/src/Controller/BaseController.php index 474d093d76..1bac9ad3cf 100644 --- a/webapp/src/Controller/BaseController.php +++ b/webapp/src/Controller/BaseController.php @@ -13,6 +13,7 @@ use App\Entity\ScoreCache; use App\Entity\Team; use App\Entity\TeamCategory; +use App\Entity\TestcaseAggregationType; use App\Service\DOMJudgeService; use App\Service\EventLogService; use App\Utils\Utils; diff --git a/webapp/src/Controller/Jury/ImportExportController.php b/webapp/src/Controller/Jury/ImportExportController.php index 6f07dfd71a..211100966d 100644 --- a/webapp/src/Controller/Jury/ImportExportController.php +++ b/webapp/src/Controller/Jury/ImportExportController.php @@ -157,6 +157,7 @@ public function indexAction(Request $request): Response try { $zip = $this->dj->openZipFile($archive->getRealPath()); $clientName = $archive->getClientOriginalName(); + /** @var array $messages */ $messages = []; if ($contestId === null) { $contest = null; diff --git a/webapp/src/Controller/Jury/ProblemController.php b/webapp/src/Controller/Jury/ProblemController.php index dfe2969d17..2f4906b6d4 100644 --- a/webapp/src/Controller/Jury/ProblemController.php +++ b/webapp/src/Controller/Jury/ProblemController.php @@ -956,6 +956,7 @@ public function editAction(Request $request, int $probId): Response $data = $uploadForm->getData(); /** @var UploadedFile $archive */ $archive = $data['archive']; + /** @var array $messages */ $messages = []; /** @var Contest|null $contest */ diff --git a/webapp/src/DataTransferObject/Shadowing/JudgementEvent.php b/webapp/src/DataTransferObject/Shadowing/JudgementEvent.php index 5f94cf9140..2c72865c28 100644 --- a/webapp/src/DataTransferObject/Shadowing/JudgementEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/JudgementEvent.php @@ -10,5 +10,6 @@ public function __construct( public readonly string $submissionId, public readonly ?string $endTime, public readonly ?string $judgementTypeId, + public readonly string|float|null $score, ) {} } diff --git a/webapp/src/DataTransferObject/Shadowing/RunEvent.php b/webapp/src/DataTransferObject/Shadowing/RunEvent.php index c476963144..0315aadd51 100644 --- a/webapp/src/DataTransferObject/Shadowing/RunEvent.php +++ b/webapp/src/DataTransferObject/Shadowing/RunEvent.php @@ -11,5 +11,6 @@ public function __construct( public readonly ?string $judgementTypeId, public readonly ?string $time, public readonly ?float $runTime, + public readonly string|float|null $score, ) {} } diff --git a/webapp/src/Entity/ExternalJudgement.php b/webapp/src/Entity/ExternalJudgement.php index 95bc555897..da8174d7f9 100644 --- a/webapp/src/Entity/ExternalJudgement.php +++ b/webapp/src/Entity/ExternalJudgement.php @@ -84,6 +84,17 @@ class ExternalJudgement )] private bool $valid = true; + #[ORM\Column( + type: 'decimal', + precision: 32, + scale: 9, + options: [ + 'comment' => 'Optional score for this run, e.g. for partial scoring', + 'default' => '0.000000000', + ] + )] + private string|float $score = 0; + #[ORM\ManyToOne] #[ORM\JoinColumn(name: 'cid', referencedColumnName: 'cid', onDelete: 'CASCADE')] private Contest $contest; @@ -249,4 +260,15 @@ public function getSumRuntime(): float } return $sum; } + + public function getScore(): string|float + { + return $this->score; + } + + public function setScore(string|float $score): ExternalJudgement + { + $this->score = $score; + return $this; + } } diff --git a/webapp/src/Entity/ExternalRun.php b/webapp/src/Entity/ExternalRun.php index cd6e86ed16..68c6264876 100644 --- a/webapp/src/Entity/ExternalRun.php +++ b/webapp/src/Entity/ExternalRun.php @@ -62,6 +62,17 @@ class ExternalRun #[ORM\JoinColumn(name: 'cid', referencedColumnName: 'cid', onDelete: 'CASCADE')] private Contest $contest; + #[ORM\Column( + type: 'decimal', + precision: 32, + scale: 9, + options: [ + 'comment' => 'Optional score for this run, e.g. for partial scoring', + 'default' => '0.000000000', + ] + )] + private string|float $score = 0; + public function getExtrunid(): int { return $this->extrunid; diff --git a/webapp/src/Entity/Judging.php b/webapp/src/Entity/Judging.php index da14eff906..76912df6e0 100644 --- a/webapp/src/Entity/Judging.php +++ b/webapp/src/Entity/Judging.php @@ -176,6 +176,28 @@ class Judging extends BaseApiEntity #[Serializer\Exclude] private ?InternalError $internalError = null; + #[ORM\Column( + type: 'decimal', + precision: 32, + scale: 9, + options: [ + 'comment' => 'Optional score for this run, e.g. for partial scoring', + 'default' => '0.000000000', + ] + )] + private string|float $score = 0; + + public function setScore(string|float $score): Judging + { + $this->score = $score; + return $this; + } + + public function getScore(): string|float + { + return $this->score; + } + public function getMaxRuntime(): ?float { if ($this->runs->isEmpty()) { diff --git a/webapp/src/Entity/JudgingRun.php b/webapp/src/Entity/JudgingRun.php index 88fddf5436..f1ef3f4275 100644 --- a/webapp/src/Entity/JudgingRun.php +++ b/webapp/src/Entity/JudgingRun.php @@ -60,6 +60,17 @@ class JudgingRun extends BaseApiEntity #[Serializer\Exclude] private string|float|null $endtime = null; + #[ORM\Column( + type: 'decimal', + precision: 32, + scale: 9, + options: [ + 'comment' => 'Optional score for this run, e.g. for partial scoring', + 'default' => '0.000000000', + ] + )] + private string|float $score = 0; + #[ORM\ManyToOne(inversedBy: 'runs')] #[ORM\JoinColumn(name: 'judgingid', referencedColumnName: 'judgingid', onDelete: 'CASCADE')] #[Serializer\Exclude] @@ -162,6 +173,16 @@ public function getEndtime(): string|float|null return $this->endtime; } + public function setScore(string|float $score): JudgingRun + { + $this->score = $score; + return $this; + } + public function getScore(): string|float + { + return $this->score; + } + #[Serializer\VirtualProperty] #[Serializer\SerializedName('time')] #[Serializer\Type('string')] diff --git a/webapp/src/Entity/Problem.php b/webapp/src/Entity/Problem.php index 688063fec9..6926a038ca 100644 --- a/webapp/src/Entity/Problem.php +++ b/webapp/src/Entity/Problem.php @@ -211,6 +211,11 @@ class Problem extends BaseApiEntity implements #[Serializer\Exclude] private Collection $languages; + #[ORM\ManyToOne(inversedBy: 'problems')] + #[ORM\JoinColumn(name: 'parent_testcase_group_id', referencedColumnName: 'testcase_group_id', nullable: true, onDelete: 'SET NULL')] + #[Serializer\Exclude] + private ?TestcaseGroup $parentTestcaseGroup = null; + public function setProbid(int $probid): Problem { $this->probid = $probid; @@ -654,4 +659,15 @@ public function removeLanguage(Language $language): Problem $this->languages->removeElement($language); return $this; } + + public function setParentTestcaseGroup(?TestcaseGroup $parentTestcaseGroup): Problem + { + $this->parentTestcaseGroup = $parentTestcaseGroup; + return $this; + } + + public function getParentTestcaseGroup(): ?TestcaseGroup + { + return $this->parentTestcaseGroup; + } } diff --git a/webapp/src/Entity/RankCache.php b/webapp/src/Entity/RankCache.php index d1f25640eb..c99c0d5498 100644 --- a/webapp/src/Entity/RankCache.php +++ b/webapp/src/Entity/RankCache.php @@ -79,6 +79,28 @@ class RankCache )] private string $sortKeyRestricted = ''; + #[ORM\Column( + type: 'decimal', + precision: 32, + scale: 9, + options: [ + 'comment' => 'Optional score for this run, e.g. for partial scoring', + 'default' => '0.000000000', + ] + )] + private string|float $scorePublic = 0; + + #[ORM\Column( + type: 'decimal', + precision: 32, + scale: 9, + options: [ + 'comment' => 'Optional score for this run, e.g. for partial scoring (for restricted audience)', + 'default' => '0.000000000', + ] + )] + private string|float $scoreRestricted = 0; + public function setPointsRestricted(int $pointsRestricted): RankCache { $this->points_restricted = $pointsRestricted; @@ -188,4 +210,26 @@ public function getSortKeyRestricted(): string { return $this->sortKeyRestricted; } + + public function setScorePublic(string|float $scorePublic): RankCache + { + $this->scorePublic = $scorePublic; + return $this; + } + + public function getScorePublic(): string|float + { + return $this->scorePublic; + } + + public function setScoreRestricted(string|float $scoreRestricted): RankCache + { + $this->scoreRestricted = $scoreRestricted; + return $this; + } + + public function getScoreRestricted(): string|float + { + return $this->scoreRestricted; + } } diff --git a/webapp/src/Entity/ScoreCache.php b/webapp/src/Entity/ScoreCache.php index 67aa8bcf0b..52ed6bc309 100644 --- a/webapp/src/Entity/ScoreCache.php +++ b/webapp/src/Entity/ScoreCache.php @@ -94,6 +94,28 @@ class ScoreCache ])] private bool $is_first_to_solve = false; + #[ORM\Column( + type: 'decimal', + precision: 32, + scale: 9, + options: [ + 'comment' => 'Optional score for this run, e.g. for partial scoring', + 'default' => '0.000000000', + ] + )] + private string|float $scorePublic = 0; + + #[ORM\Column( + type: 'decimal', + precision: 32, + scale: 9, + options: [ + 'comment' => 'Optional score for this run, e.g. for partial scoring (for restricted audience)', + 'default' => '0.000000000', + ] + )] + private string|float $scoreRestricted = 0; + #[ORM\Id] #[ORM\ManyToOne] #[ORM\JoinColumn(name: 'cid', referencedColumnName: 'cid', onDelete: 'CASCADE')] @@ -287,4 +309,9 @@ public function getIsCorrect(bool $restricted): bool { return $restricted ? $this->getIsCorrectRestricted() : $this->getIsCorrectPublic(); } + + public function getScore(bool $restricted): string|float + { + return $restricted ? $this->scoreRestricted : $this->scorePublic; + } } diff --git a/webapp/src/Entity/Submission.php b/webapp/src/Entity/Submission.php index 74f481c4d0..5626dff74a 100644 --- a/webapp/src/Entity/Submission.php +++ b/webapp/src/Entity/Submission.php @@ -202,6 +202,16 @@ public function getResult(): ?string return null; } + public function getScore(): ?string + { + foreach ($this->judgings as $j) { + if ($j->getValid()) { + return $j->getScore(); + } + } + return null; + } + public function getSubmitid(): int { return $this->submitid; diff --git a/webapp/src/Entity/Testcase.php b/webapp/src/Entity/Testcase.php index e52ad665f8..64027c3b70 100644 --- a/webapp/src/Entity/Testcase.php +++ b/webapp/src/Entity/Testcase.php @@ -116,6 +116,11 @@ class Testcase #[Serializer\Exclude] private ?Problem $problem = null; + #[ORM\ManyToOne(inversedBy: 'testcases')] + #[ORM\JoinColumn(name: 'testcase_group_id', referencedColumnName: 'testcase_group_id', onDelete: 'SET NULL')] + #[Serializer\Exclude] + private ?TestcaseGroup $testcaseGroup = null; + public function __construct() { $this->judging_runs = new ArrayCollection(); @@ -292,6 +297,17 @@ public function getProblem(): ?Problem return $this->problem; } + public function setTestcaseGroup(?TestcaseGroup $testcaseGroup = null): Testcase + { + $this->testcaseGroup = $testcaseGroup; + return $this; + } + + public function getTestcaseGroup(): ?TestcaseGroup + { + return $this->testcaseGroup; + } + public function addExternalRun(ExternalRun $externalRun): Testcase { $this->external_runs[] = $externalRun; diff --git a/webapp/src/Entity/TestcaseAggregationType.php b/webapp/src/Entity/TestcaseAggregationType.php new file mode 100644 index 0000000000..ee04e1f607 --- /dev/null +++ b/webapp/src/Entity/TestcaseAggregationType.php @@ -0,0 +1,11 @@ + 'utf8mb4_unicode_ci', + 'charset' => 'utf8mb4', + 'comment' => 'Testcase group metadata', + ] +)] +class TestcaseGroup +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(options: ['comment' => 'Testcase group ID', 'unsigned' => true])] + private int $testcaseGroupId; + + #[ORM\Column(type: 'string', length: 255, options: ['comment' => 'Name of the testcase group'])] + private string $name; + + #[ORM\Column(type: 'string', length: 255, nullable: true, options: ['comment' => 'Score if this group is accepted'])] + private ?string $acceptScore = null; + + #[ORM\Column(type: 'string', length: 255, nullable: true, options: ['comment' => 'Lower bound of the score range'])] + private ?string $rangeLowerBound = null; + + #[ORM\Column(type: 'string', length: 255, nullable: true, options: ['comment' => 'Upper bound of the score range'])] + private ?string $rangeUpperBound = null; + + #[ORM\Column(type: 'string', enumType: TestcaseAggregationType::class, options: ['default' => 'sum', 'comment' => 'How to aggregate scores for this group'])] + private TestcaseAggregationType $aggregationType = TestcaseAggregationType::SUM; + + #[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Ignore the sample testcases when aggregating scores'])] + private bool $ignoreSample = false; + + #[ORM\Column(type: 'string', length: 255, nullable: true, options: ['comment' => 'Flags for output validation'])] + private string $outputValidatorFlags = ''; + + #[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Continue on reject'])] + private bool $onRejectContinue = false; + + #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] + #[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'testcase_group_id', nullable: true, onDelete: 'SET NULL')] + private ?self $parent = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')] + private Collection $children; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Testcase::class, mappedBy: 'testcaseGroup')] + private Collection $testcases; + + public function __construct() + { + $this->children = new ArrayCollection(); + $this->testcases = new ArrayCollection(); + } + + public function getTestcaseGroupId(): int + { + return $this->testcaseGroupId; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getAcceptScore(): ?string + { + return $this->acceptScore; + } + + public function setAcceptScore(?string $acceptScore): self + { + $this->acceptScore = $acceptScore; + return $this; + } + + public function getRangeLowerBound(): ?string + { + return $this->rangeLowerBound; + } + + public function setRangeLowerBound(?string $rangeLowerBound): self + { + $this->rangeLowerBound = $rangeLowerBound; + return $this; + } + + public function getRangeUpperBound(): ?string + { + return $this->rangeUpperBound; + } + + public function setRangeUpperBound(?string $rangeUpperBound): self + { + $this->rangeUpperBound = $rangeUpperBound; + return $this; + } + + public function getAggregationType(): TestcaseAggregationType + { + return $this->aggregationType; + } + + public function setAggregationType(TestcaseAggregationType $aggregationType): self + { + $this->aggregationType = $aggregationType; + return $this; + } + + public function isIgnoreSample(): bool + { + return $this->ignoreSample; + } + + public function setIgnoreSample(bool $ignoreSample): self + { + $this->ignoreSample = $ignoreSample; + return $this; + } + + public function getParent(): ?TestcaseGroup + { + return $this->parent; + } + + public function setParent(?TestcaseGroup $parent): self + { + $this->parent = $parent; + return $this; + } + + /** + * @return Collection + */ + public function getChildren(): Collection + { + return $this->children; + } + + /** + * @return Collection + */ + public function getTestcases(): Collection + { + return $this->testcases; + } + + public function getOutputValidatorFlags(): string + { + return $this->outputValidatorFlags; + } + + public function setOutputValidatorFlags(string $outputValidatorFlags): self + { + $this->outputValidatorFlags = $outputValidatorFlags; + return $this; + } + + public function isOnRejectContinue(): bool + { + return $this->onRejectContinue; + } + + public function setOnRejectContinue(bool $onRejectContinue): self + { + $this->onRejectContinue = $onRejectContinue; + return $this; + } +} diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index 8571dfa81f..73a6448a66 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -1445,17 +1445,20 @@ public function getRunConfig(ContestProblem $problem, Submission $submission, in ); } - public function getCompareConfig(ContestProblem $problem): string + public function getCompareConfig(ContestProblem $contestProblem, ?string $outputValidatorFlags = null): string { - $compareExecutable = $this->getImmutableCompareExecutable($problem); + $compareExecutable = $this->getImmutableCompareExecutable($contestProblem); + $problem = $contestProblem->getProblem(); + $outputValidatorFlags = $outputValidatorFlags ?? $problem->getSpecialCompareArgs(); return Utils::jsonEncode( [ 'script_timelimit' => $this->config->get('script_timelimit'), 'script_memory_limit' => $this->config->get('script_memory_limit'), 'script_filesize_limit' => $this->config->get('script_filesize_limit'), - 'compare_args' => $problem->getProblem()->getSpecialCompareArgs(), - 'combined_run_compare' => $problem->getProblem()->isInteractiveProblem(), + 'compare_args' => $outputValidatorFlags, + 'combined_run_compare' => $problem->isInteractiveProblem(), 'hash' => $compareExecutable->getHash(), + 'is_scoring_problem' => $problem->isScoringProblem(), ] ); } @@ -1604,8 +1607,8 @@ private function actuallyCreateJudgetasks(int $priority, Judging $judging, int $ ':run_script_id' => $this->getImmutableRunExecutable($problem)->getImmutableExecId(), ':compile_config' => $this->getCompileConfig($submission), ':run_config' => $this->getRunConfig($problem, $submission, $overshoot), - ':compare_config' => $this->getCompareConfig($problem), ]; + $defaultCompareConfig = $this->getCompareConfig($problem); $judgetaskDefaultParamNames = array_keys($judgetaskInsertParams); @@ -1619,17 +1622,26 @@ private function actuallyCreateJudgetasks(int $priority, Judging $judging, int $ /** @var Testcase $testcase */ foreach ($testcases as $testcase) { $judgetaskInsertParts[] = sprintf( - '(%s, :testcase_id%d, :testcase_hash%d)', + '(%s, :testcase_id%d, :testcase_hash%d, :compare_config%d)', implode(', ', $judgetaskDefaultParamNames), $testcase->getTestcaseid(), - $testcase->getTestcaseid() + $testcase->getTestcaseid(), + $testcase->getTestcaseid(), ); + $compareConfig = $defaultCompareConfig; + if ($testcase->getTestcaseGroup() != null) { + $tcGroup = $testcase->getTestcaseGroup(); + if ($tcGroup->getOutputValidatorFlags() != '') { + $compareConfig = $this->getCompareConfig($problem, $tcGroup->getOutputValidatorFlags()); + } + } $judgetaskInsertParams[':testcase_id' . $testcase->getTestcaseid()] = $testcase->getTestcaseid(); $judgetaskInsertParams[':testcase_hash' . $testcase->getTestcaseid()] = $testcase->getTestcaseHash(); + $judgetaskInsertParams[':compare_config' . $testcase->getTestcaseid()] = $compareConfig; } $judgetaskColumns = array_map(fn(string $column) => substr($column, 1), $judgetaskDefaultParamNames); $judgetaskInsertQuery = sprintf( - 'INSERT INTO judgetask (%s, testcase_id, testcase_hash) VALUES %s', + 'INSERT INTO judgetask (%s, testcase_id, testcase_hash, compare_config) VALUES %s', implode(', ', $judgetaskColumns), implode(', ', $judgetaskInsertParts) ); diff --git a/webapp/src/Service/ExternalContestSourceService.php b/webapp/src/Service/ExternalContestSourceService.php index 7970b6b134..3889726dbe 100644 --- a/webapp/src/Service/ExternalContestSourceService.php +++ b/webapp/src/Service/ExternalContestSourceService.php @@ -1705,6 +1705,10 @@ protected function importJudgement(Event $event, EventData $data): void ->setEndtime($endTime) ->setResult($judgementTypeId === null ? null : $verdictsFlipped[$judgementTypeId]); + if (isset($data->score)) { + $judgement->setScore($data->score); + } + if ($persist) { $this->em->persist($judgement); } @@ -1855,6 +1859,10 @@ protected function importRun(Event $event, EventData $data): void ->setRuntime($runTime) ->setResult($judgementTypeId === null ? null : $verdictsFlipped[$judgementTypeId]); + if (isset($data->score)) { + $run->setScore($data->score); + } + if ($persist) { $this->em->persist($run); } diff --git a/webapp/src/Service/ImportProblemService.php b/webapp/src/Service/ImportProblemService.php index b9cbd94c6f..9ca23b1614 100644 --- a/webapp/src/Service/ImportProblemService.php +++ b/webapp/src/Service/ImportProblemService.php @@ -14,7 +14,9 @@ use App\Entity\SubmissionSource; use App\Entity\Team; use App\Entity\Testcase; +use App\Entity\TestcaseAggregationType; use App\Entity\TestcaseContent; +use App\Entity\TestcaseGroup; use App\Utils\Utils; use Doctrine\DBAL\Exception as DBALException; use Doctrine\ORM\EntityManagerInterface; @@ -32,6 +34,7 @@ use Symfony\Component\Validator\ConstraintViolationInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Yaml\Yaml; +use ValueError; use ZipArchive; class ImportProblemService @@ -57,9 +60,9 @@ public function __construct( public function importZippedProblem( ZipArchive $zip, string $clientName, - ?Problem $problem = null, - ?Contest $contest = null, - ?array &$messages = [] + ?Problem $problem, + ?Contest $contest, + array &$messages ): ?Problem { // This might take a while. Utils::extendMaxExecutionTime(300); @@ -339,6 +342,47 @@ public function importZippedProblem( $rank = $startRank; $actualRank = 1; + // Now we need to parse metadata for test case groups so it can be referenced when dealing with test cases. + $testCaseGroups = []; + foreach (['', 'sample/', 'secret/'] as $type) { + for ($j = 0; $j < $zip->numFiles; $j++) { + $filename = $zip->getNameIndex($j); + foreach (['test_group.yaml', 'testdata.yaml'] as $metafile) { + if (Utils::startsWith($filename, sprintf('data/%s', $type)) && Utils::endsWith($filename, sprintf('/%s', $metafile))) { + $fileContent = $zip->getFromName($filename); + if ($fileContent === false) { + $messages['warning'][] = sprintf("Could not read test group metadata file '%s'.", $filename); + continue; + } + try { + $dir = dirname($filename); + $testcaseGroup = $this->parseTestCaseGroupMeta($fileContent, $dir, $messages); + if (!$testcaseGroup) { + $messages['danger'][] = sprintf("Could not parse test group metadata file '%s'.", $filename); + continue; + } + $testCaseGroups[$dir] = $testcaseGroup; + } catch (Exception $e) { + $messages['warning'][] = sprintf("Could not parse test group metadata file '%s': %s", $filename, $e->getMessage()); + } + } + } + } + } + foreach ($testCaseGroups as $dir => $testCaseGroup) { + $parentDir = dirname($dir); + $messages['warning'][] = sprintf("Looking for %s in tc group %s", $parentDir, $dir); + if (isset($testCaseGroups[$parentDir])) { + $testCaseGroups[$dir]->setParent($testCaseGroups[$parentDir]); + } + } + $messages['warning'][] = 'Array keys: ' . join(', ', array_keys($testCaseGroups)); + if (array_key_exists('data', $testCaseGroups)) { + $problem->setParentTestcaseGroup($testCaseGroups['data']); + $messages['info'][] = 'Set parent testcase group for problem to "data", with ID ' . + $testCaseGroups['data']->getTestcaseGroupId(); + } + // First insert sample, then secret data in alphabetical order. foreach (['sample', 'secret'] as $type) { $numCases = 0; @@ -356,6 +400,8 @@ public function importZippedProblem( } } asort($dataFiles, SORT_STRING); + $messages['info'][] = sprintf("Found %d %s testcase(s): {%s}.{in,ans}", + count($dataFiles), $type, join(',', $dataFiles)); foreach ($dataFiles as $dataFile) { $baseFileName = sprintf('data/%s/%s', $type, $dataFile); @@ -408,6 +454,12 @@ public function importZippedProblem( $md5in = md5($testInput); $md5out = md5($testOutput); + $testcaseGroup = null; + $dir = dirname($baseFileName); + if (isset($testCaseGroups[$dir])) { + $testcaseGroup = $testCaseGroups[$dir]; + } + // Check if we have an existing testcase with the same data. $index = sprintf('%s-%s-%s', $md5in, $md5out, $dataFile); $touchedTestcases[$index] = $index; @@ -455,6 +507,9 @@ public function importZippedProblem( ->setImage($imageFile) ->setImageThumb($imageThumb); } + if ($testcaseGroup) { + $testcase->setTestcaseGroup($testcaseGroup); + } $this->em->persist($testcase); $rank++; @@ -868,6 +923,7 @@ public function importProblemFromRequest(Request $request, ?int $contestId = nul try { $zip = $this->dj->openZipFile($file->getRealPath()); $clientName = $file->getClientOriginalName(); + /** @var array $messages */ $messages = []; $newProblem = $this->importZippedProblem( $zip, $clientName, $problem, $contest, $messages @@ -894,9 +950,9 @@ public function importProblemFromRequest(Request $request, ?int $contestId = nul } /** - * @param array{danger?: string[], info?: string[]} $messages + * @param array $messages */ - private function searchAndAddValidator(ZipArchive $zip, ?array &$messages, string $externalId, string $validationMode, ?Problem $problem): bool + private function searchAndAddValidator(ZipArchive $zip, array &$messages, string $externalId, string $validationMode, ?Problem $problem): bool { $validatorFiles = []; for ($i = 0; $i < $zip->numFiles; $i++) { @@ -1000,6 +1056,64 @@ private function searchAndAddValidator(ZipArchive $zip, ?array &$messages, strin return true; } + /** + * @param array $messages + */ + public function parseTestCaseGroupMeta(string $fileContent, string $name, array &$messages): ?TestcaseGroup + { + $yamlData = Yaml::parse($fileContent); + if (empty($yamlData)) { + return null; + } + $testcaseGroup = new TestcaseGroup(); + $testcaseGroup->setName($name); + if (isset($yamlData['accept_score'])) { + $value = $yamlData['accept_score']; + if (!is_numeric($value)) { + $messages['danger'][] = sprintf("Invalid accept_score '%s' in test group '%s'.", $value, $name); + return null; + } + $testcaseGroup = $testcaseGroup->setAcceptScore(Utils::numericToBcMath($yamlData['accept_score'])); + } + if (isset($yamlData['range'])) { + $range = preg_split('/\s+/', $yamlData['range']); + if (count($range) != 2 || !is_numeric($range[0]) || !is_numeric($range[1])) { + $messages['danger'][] = sprintf("Invalid range '%s' in test group '%s'.", $yamlData['range'], $name); + return null; + } + $testcaseGroup->setRangeLowerBound(Utils::numericToBcMath($range[0])); + $testcaseGroup->setRangeUpperBound(Utils::numericToBcMath($range[1])); + } + if (isset($yamlData['grader_flags'])) { + $flags = preg_split('/\s+/', $yamlData['grader_flags']); + foreach ($flags as $flag) { + if (in_array($flag, ['sum', 'max', 'min', 'avg'])) { + try { + $aggregationType = TestcaseAggregationType::tryFrom($flag); + $testcaseGroup->setAggregationType($aggregationType); + } catch (ValueError $e) { + $messages['danger'][] = sprintf("Invalid aggregation type '%s' in test group '%s'.", $flag, $name); + return null; + } + } + if ($flag === 'ignore_sample') { + $testcaseGroup->setIgnoreSample(true); + } + // Silently ignore currently unused flags. + // TODO: add support for the remaining flags and error out on unknown flags. + } + } + if (isset($yamlData['output_validator_flags'])) { + $testcaseGroup->setOutputValidatorFlags($yamlData['output_validator_flags']); + } + if (isset($yamlData['on_reject'])) { + $testcaseGroup->setOnRejectContinue($yamlData['on_reject'] === 'continue'); + } + $this->em->persist($testcaseGroup); + $this->em->flush(); + return $testcaseGroup; + } + /** * Returns true iff the yaml could be parsed correctly. * diff --git a/webapp/src/Service/ScoreboardService.php b/webapp/src/Service/ScoreboardService.php index 21d8b60178..659267efa8 100644 --- a/webapp/src/Service/ScoreboardService.php +++ b/webapp/src/Service/ScoreboardService.php @@ -8,6 +8,7 @@ use App\Entity\Judging; use App\Entity\Problem; use App\Entity\RankCache; +use App\Entity\ScoreboardType; use App\Entity\ScoreCache; use App\Entity\Submission; use App\Entity\Team; @@ -259,6 +260,8 @@ public function calculateScoreRow( $contestStartTime = $contest->getStarttime(); + $pointsJury = "0"; + $pointsPubl = "0"; foreach ($submissions as $submission) { /** @var Judging|ExternalJudgement|null $judging */ if ($useExternalJudgements) { @@ -267,6 +270,19 @@ public function calculateScoreRow( $judging = $submission->getJudgings()->first() ?: null; } + if ($problem->isScoringProblem()) { + $score = $judging->getScore(); + if ($score !== null) { + if (bccomp($pointsJury, $score, scale: self::SCALE) < 0) { + $pointsJury = $score; + } + if (!$submission->isAfterFreeze() && + bccomp($pointsPubl, $score, scale: self::SCALE) < 0) { + $pointsPubl = $score; + } + } + } + // three things will happen in the loop in this order: // 1. update fastest runtime // 2. count submissions until correct submission @@ -409,19 +425,21 @@ public function calculateScoreRow( 'solvetimeRestricted' => (int)$timeJury, 'runtimeRestricted' => $runtimeJury === PHP_INT_MAX ? 0 : $runtimeJury, 'isCorrectRestricted' => (int)$correctJury, + 'scoreRestricted' => $pointsJury, 'submissionsPublic' => $submissionsPubl, 'pendingPublic' => $pendingPubl, 'solvetimePublic' => (int)$timePubl, 'runtimePublic' => $runtimePubl === PHP_INT_MAX ? 0 : $runtimePubl, 'isCorrectPublic' => (int)$correctPubl, + 'scorePublic' => $pointsPubl, 'isFirstToSolve' => (int)$firstToSolve, ]; $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) - VALUES (:cid, :teamid, :probid, :submissionsRestricted, :pendingRestricted, :solvetimeRestricted, :runtimeRestricted, :isCorrectRestricted, - :submissionsPublic, :pendingPublic, :solvetimePublic, :runtimePublic, :isCorrectPublic, :isFirstToSolve)', $params); + submissions_restricted, pending_restricted, solvetime_restricted, runtime_restricted, is_correct_restricted, score_restricted, + submissions_public, pending_public, solvetime_public, runtime_public, is_correct_public, score_public, is_first_to_solve) + VALUES (:cid, :teamid, :probid, :submissionsRestricted, :pendingRestricted, :solvetimeRestricted, :runtimeRestricted, :isCorrectRestricted, :scoreRestricted, + :submissionsPublic, :pendingPublic, :solvetimePublic, :runtimePublic, :isCorrectPublic, :scorePublic, :isFirstToSolve)', $params); if ($this->em->getConnection()->fetchOne('SELECT RELEASE_LOCK(:lock)', ['lock' => $lockString]) != 1) { @@ -478,11 +496,13 @@ public function updateRankCache(Contest $contest, Team $team): void $totalTime = []; $totalRuntime = []; $timeOfLastCorrect = []; + $score = []; foreach ($variants as $variant => $isRestricted) { $numPoints[$variant] = 0; $totalTime[$variant] = $team->getPenalty(); $totalRuntime[$variant] = 0; $timeOfLastCorrect[$variant] = 0; + $score[$variant] = "0"; } $penaltyTime = (int) $this->config->get('penalty_time'); @@ -517,16 +537,24 @@ public function updateRankCache(Contest $contest, Team $team): void $timeOfLastCorrect[$variant] = max($timeOfLastCorrect[$variant], $solveTimeForProblem); $totalTime[$variant] += $solveTimeForProblem + $penalty; $totalRuntime[$variant] += $scoreCacheCell->getRuntime($isRestricted); + $score[$variant] = bcadd($score[$variant], $scoreCacheCell->getScore($isRestricted), self::SCALE); } } } foreach ($variants as $variant => $isRestricted) { - $scoreKey[$variant] = self::getICPCScoreKey( - $numPoints[$variant], - $totalTime[$variant], - $timeOfLastCorrect[$variant] - ); + if ($contest->getScoreboardType() == ScoreboardType::PASS_FAIL) { + $scoreKey[$variant] = self::getICPCScoreKey( + $numPoints[$variant], + $totalTime[$variant], + $timeOfLastCorrect[$variant] + ); + } else { + // TODO: Any tie breakers? + $scoreKey[$variant] = self::getScoringScoreKey( + $score[$variant], + ); + } } // Use a direct REPLACE INTO query to drastically speed this up. @@ -541,12 +569,14 @@ public function updateRankCache(Contest $contest, Team $team): void 'totalRuntimePublic' => $totalRuntime['public'], 'sortKeyRestricted' => $scoreKey['restricted'], 'sortKeyPublic' => $scoreKey['public'], + 'scoreRestricted' => $score['restricted'], + 'scorePublic' => $score['public'], ]; $this->em->getConnection()->executeQuery('REPLACE INTO rankcache (cid, teamid, - points_restricted, totaltime_restricted, totalruntime_restricted, - points_public, totaltime_public, totalruntime_public, sort_key_restricted, sort_key_public) - VALUES (:cid, :teamid, :pointsRestricted, :totalTimeRestricted, :totalRuntimeRestricted, - :pointsPublic, :totalTimePublic, :totalRuntimePublic, :sortKeyRestricted, :sortKeyPublic)', $params); + points_restricted, totaltime_restricted, totalruntime_restricted, score_restricted, + points_public, totaltime_public, totalruntime_public, sort_key_restricted, sort_key_public, score_public) + VALUES (:cid, :teamid, :pointsRestricted, :totalTimeRestricted, :totalRuntimeRestricted, :scoreRestricted, + :pointsPublic, :totalTimePublic, :totalRuntimePublic, :sortKeyRestricted, :sortKeyPublic, :scorePublic)', $params); if ($this->em->getConnection()->fetchOne('SELECT RELEASE_LOCK(:lock)', ['lock' => $lockString]) != 1) { @@ -597,6 +627,16 @@ public static function getICPCScoreKey(int $numSolved, int $totalTime, int $time return implode(',', $scoreKeyArray); } + public static function getScoringScoreKey(string $score): string + { + // For scoring problems, we only use the score as the key (for now). + // We assume that the score is a valid bcmath number. + $scoreKeyArray = [ + self::convertToScoreKeyElement($score, Order::Descending), + ]; + return implode(',', $scoreKeyArray); + } + /** * Recalculate the scoreCache and rankCache of a contest. * diff --git a/webapp/src/Service/SubmissionService.php b/webapp/src/Service/SubmissionService.php index 792038885d..ee94cdac45 100644 --- a/webapp/src/Service/SubmissionService.php +++ b/webapp/src/Service/SubmissionService.php @@ -13,6 +13,8 @@ use App\Entity\SubmissionFile; use App\Entity\SubmissionSource; use App\Entity\Team; +use App\Entity\TestcaseAggregationType; +use App\Entity\TestcaseGroup; use App\Entity\User; use App\Utils\FreezeData; use App\Utils\Utils; @@ -25,6 +27,7 @@ use Knp\Component\Pager\Pagination\PaginationInterface; use Knp\Component\Pager\PaginatorInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -56,6 +59,142 @@ public function __construct( protected readonly PaginatorInterface $paginator, ) {} + /** + * Returns a two-element array: + * - The score for the testcase group, or null if not all results are ready. + * - The result for the testcase group, or null if not all results are ready. + * @return array{string|null, string|null} + */ + public static function maybeSetScoringResult(TestcaseGroup $testcaseGroup, Judging $judging): array + { + $allResultsReady = true; + $allCorrect = true; + $firstIncorrectVerdict = null; + $results = []; + $ignoreSample = $testcaseGroup->isIgnoreSample(); + + // TODO: check whether it is allowed to mix groups and directs. Assume for that this is not the case. + if ($testcaseGroup->getChildren()->isEmpty()) { + if ($testcaseGroup->getAcceptScore() !== null) { + $acceptScore = $testcaseGroup->getAcceptScore(); + $judgingRuns = $judging->getRuns(); + // TODO: There is likely a more elegant way to get the runs for this testcase group. + $relevantRuns = []; + foreach ($judgingRuns as $run) { + $testcase = $run->getTestcase(); + if ($testcase->getTestcaseGroup() === $testcaseGroup) { + $relevantRuns[] = $run; + if ($run->getRunresult() === null || $run->getRunresult() === '') { + $allResultsReady = false; + } else if ($run->getRunresult() !== 'correct') { + $allCorrect = false; + if ($firstIncorrectVerdict === null) { + $firstIncorrectVerdict = $run->getRunresult(); + } + } + } + } + if (count($relevantRuns) > 0) { + if ($allCorrect) { + $results[] = $acceptScore; + } else { + $results[] = 0; + } + } + } else { + // TODO: Reduce code duplication with the code above/below. + $judgingRuns = $judging->getRuns(); + foreach ($judgingRuns as $run) { + $testcase = $run->getTestcase(); + if ($testcase->getTestcaseGroup() === $testcaseGroup) { + $results[] = $run->getScore(); + if ($run->getRunresult() === null || $run->getRunresult() === '') { + $allResultsReady = false; + } else if ($run->getRunresult() !== 'correct') { + $allCorrect = false; + if ($firstIncorrectVerdict === null) { + $firstIncorrectVerdict = $run->getRunresult(); + } + } + } + } + } + } else { + foreach ($testcaseGroup->getChildren() as $childGroup) { + if ($ignoreSample && $childGroup->getName() === 'data/sample') { + continue; + } + $childScoreAndResult = self::maybeSetScoringResult( + $childGroup, + $judging + ); + $childScore = $childScoreAndResult[0]; + $childResult = $childScoreAndResult[1]; + // TODO: Reduce code duplication with the code above. + if ($childResult === null || $childResult === '') { + $allResultsReady = false; + } else if ($childResult !== 'correct') { + $allCorrect = false; + if ($firstIncorrectVerdict === null) { + $firstIncorrectVerdict = $childResult; + } + } else { + $results[] = $childScore; + } + } + } + + $testcaseAggregationType = $testcaseGroup->getAggregationType(); + switch ($testcaseAggregationType) { + case TestcaseAggregationType::SUM: + case TestcaseAggregationType::AVG: + $score = "0"; + foreach ($results as $result) { + if ($result === null) { + $allResultsReady = false; + break; + } else { + $score = bcadd($score, $result, ScoreboardService::SCALE); + } + } + if ($testcaseAggregationType === TestcaseAggregationType::AVG && count($results) > 0) { + $score = bcdiv($score, (string)count($results), ScoreboardService::SCALE); + } + break; + case TestcaseAggregationType::MIN: + case TestcaseAggregationType::MAX: + $score = null; + foreach ($results as $result) { + if ($result === null) { + $allResultsReady = false; + break; + } elseif ($score === null) { + $score = $result; + } else { + if ($testcaseAggregationType === TestcaseAggregationType::MIN + && bccomp($result, $score, ScoreboardService::SCALE) < 0) { + $score = $result; + } + if ($testcaseAggregationType === TestcaseAggregationType::MAX + && bccomp($result, $score, ScoreboardService::SCALE) > 0) { + $score = $result; + } + } + } + break; + default: + throw new InvalidArgumentException(sprintf("Unknown testcase aggregation type '%s'.", + $testcaseAggregationType->name)); + } + + if ($allResultsReady || (!$allCorrect && !$testcaseGroup->isOnRejectContinue())) { + $score = (string)bcadd((string)$score, '0', ScoreboardService::SCALE); + $result = $allCorrect ? 'correct' : $firstIncorrectVerdict ?? 'judge-error'; + return [$score, $result]; + } + return [null, null]; + } + /** * Get a list of submissions that can be displayed in the interface using * the submission_list partial. diff --git a/webapp/src/Utils/Scoreboard/Scoreboard.php b/webapp/src/Utils/Scoreboard/Scoreboard.php index b1b36c9edb..eac81efaf3 100644 --- a/webapp/src/Utils/Scoreboard/Scoreboard.php +++ b/webapp/src/Utils/Scoreboard/Scoreboard.php @@ -163,12 +163,14 @@ protected function calculateScoreboard(): void ); $contestProblem = $scoreCell->getContest()->getContestProblem($scoreCell->getProblem()); - // TODO: For actual scoring problems, we need to calculate the score here and - // output it with the correct precision. For now, this is always an integer. - $points = strval( - $isCorrect ? - $contestProblem->getPoints() : 0 - ); + if ($scoreCell->getProblem()->isScoringProblem()) { + $points = $scoreCell->getScore($this->restricted); + } else { + $points = strval( + $isCorrect ? + $contestProblem->getPoints() : 0 + ); + } $this->matrix[$teamId][$probId] = new ScoreboardMatrixItem( isCorrect: $isCorrect, diff --git a/webapp/src/Utils/Scoreboard/TeamScore.php b/webapp/src/Utils/Scoreboard/TeamScore.php index 2d9591d85f..e386fea239 100644 --- a/webapp/src/Utils/Scoreboard/TeamScore.php +++ b/webapp/src/Utils/Scoreboard/TeamScore.php @@ -12,6 +12,7 @@ class TeamScore public int $rank = 0; public int $totalTime; public int $totalRuntime = 0; + public string|float|null $score = "0"; public function __construct(public Team $team, public ?RankCache $rankCache, bool $restricted) { @@ -21,10 +22,12 @@ public function __construct(public Team $team, public ?RankCache $rankCache, boo $this->numPoints = $rankCache->getPointsRestricted(); $this->totalTime += $rankCache->getTotaltimeRestricted(); $this->totalRuntime = $rankCache->getTotalruntimeRestricted(); + $this->score = $rankCache->getScoreRestricted(); } else { $this->numPoints = $rankCache->getPointsPublic(); $this->totalTime += $rankCache->getTotaltimePublic(); $this->totalRuntime = $rankCache->getTotalruntimePublic(); + $this->score = $rankCache->getScorePublic(); } } } diff --git a/webapp/src/Utils/Utils.php b/webapp/src/Utils/Utils.php index 7d0a191a02..d6cc1c3a83 100644 --- a/webapp/src/Utils/Utils.php +++ b/webapp/src/Utils/Utils.php @@ -1023,4 +1023,10 @@ public static function extendMaxExecutionTime(int $minimumMaxExecutionTime): voi ini_set('max_execution_time', $minimumMaxExecutionTime); } } + + public static function numericToBcMath(float|int|string $value, int $scale = 9): string + { + $value = (string)$value; + return bcadd($value, '0', $scale); + } } diff --git a/webapp/templates/jury/partials/submission_list.html.twig b/webapp/templates/jury/partials/submission_list.html.twig index 4493e5867d..e5e33c9d51 100644 --- a/webapp/templates/jury/partials/submission_list.html.twig +++ b/webapp/templates/jury/partials/submission_list.html.twig @@ -15,6 +15,11 @@ {% set tdExtraClass = ' thick-border' %} {% endif %} +{% set is_scoring = false %} +{% if current_contest and current_contest.scoreboardTypeString == 'scoring' %} + {% set is_scoring = true %} +{% endif %} + {% if submissions is empty %}
No submissions
{% else %} @@ -72,8 +77,14 @@ {% else %} {%- if rejudging is defined %}new {% endif %}result {% endif %} + {% if is_scoring %} + score + {% endif %} {% if showExternalResult and not showExternalTestcases %} - external result + ext. result + {% if is_scoring %} + ext. score + {% endif %} {% endif %} {% if not showExternalResult or not showExternalTestcases %} verified @@ -160,6 +171,15 @@ {{ submission | printValidJurySubmissionResult }} + {% if is_scoring %} + + {% if submission.score is not null %} + {{ submission.score | number_format(2, '.', '') }} + {% else %} + {{ '-' }} + {% endif %} + + {% endif %} {% if showExternalResult and not showExternalTestcases %} {% if submission.externalJudgements.empty %} {% set externalJudgement = null %} @@ -177,6 +197,15 @@ {% endif %} + {% if is_scoring %} + + {% if externalJudgement is not null and externalJudgement.score is not null %} + {{ externalJudgement.score | number_format(2, '.', '') }} + {% else %} + {{ '-' }} + {% endif %} + + {% endif %} {% endif %} {% if not showExternalResult or not showExternalTestcases %} {%- set claim = false %} diff --git a/webapp/templates/jury/submission.html.twig b/webapp/templates/jury/submission.html.twig index 7da1dda839..24b8372f23 100644 --- a/webapp/templates/jury/submission.html.twig +++ b/webapp/templates/jury/submission.html.twig @@ -294,6 +294,9 @@ max runtime judgehost result + {% if submission.problem.scoringProblem %} + score + {% endif %} rejudging @@ -332,6 +335,17 @@ {% endif %} + {% if submission.problem.scoringProblem %} + + + {% if judging.score is not null %} + {{ "%.2f" | format(judging.score) }} + {% else %} + - + {% endif %} + + + {% endif %} {% if judging.rejudging is not null %} @@ -378,6 +392,9 @@ {%- else %} {{- selectedJudging.result | printValidJuryResult -}} {%- endif %} + {% if submission.problem.scoringProblem %} + (score: {{ "%.2f" | format(selectedJudging.score) }}) + {% endif %} {%- if submission.stillBusy -%} (…) {%- endif -%} @@ -392,7 +409,11 @@ {% if submission.importError %} External result: {{ externalJudgement.result | printValidJuryResult }} {% else %} - (external: {{ externalJudgement.result | printValidJuryResult }}) + (external: {{ externalJudgement.result | printValidJuryResult }} + {% if submission.problem.scoringProblem %} + , score: {{ "%.2f" | format(externalJudgement.score) }} + {% endif %} + ) {% endif %} {%- endif %} {%- if selectedJudging is not null and judgehosts is not empty -%} @@ -663,6 +684,14 @@ {% endif %} {% endif %} + {% if submission.problem.scoringProblem %} + | Score: + {% if run.firstJudgingRun.score is not null %} + {{ "%.2f" | format(run.firstJudgingRun.score) }} + {% else %} + - + {% endif %} + {% endif %} {% endif %} {% if runsOutput[runIdx].hostname is not null %} {% set judgehostLink = path('jury_judgehost', {judgehostid: runsOutput[runIdx].judgehostid}) %} diff --git a/webapp/templates/partials/scoreboard_table.html.twig b/webapp/templates/partials/scoreboard_table.html.twig index 71b074da1d..71b32aa01c 100644 --- a/webapp/templates/partials/scoreboard_table.html.twig +++ b/webapp/templates/partials/scoreboard_table.html.twig @@ -260,7 +260,11 @@ {% set totalTime = totalTime | printTimeRelative %} {% endif %} {% if enable_ranking %} - {% set totalPoints = score.numPoints %} + {% if scoringScoreboard %} + {% set totalPoints = "%.2f" | format(score.score) %} + {% else %} + {% set totalPoints = score.numPoints %} + {% endif %} {{ totalPoints }} {% if not scoringScoreboard %} {% if scoreboard.getRuntimeAsScoreTiebreaker() %} @@ -345,7 +349,8 @@ {% endif %} > {% if scoringScoreboard %} - {% if matrixItem.isCorrect %}{{ matrixItem.points }}{% else %} {% endif %} + {# TODO: Decide on precision or make it configurable. #} + {% if matrixItem.isCorrect %}{{ matrixItem.points | round(2) }}{% else %} {% endif %} {% else %} {% if matrixItem.isCorrect %}{{ time }}{% else %} {% endif %} {% endif %}