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

Allow selecting new problem types as part of the problem entity. #2979

Merged
merged 1 commit into from
Apr 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 44 additions & 0 deletions webapp/migrations/Version20250323190305.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?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 Version20250323190305 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE problem ADD types INT NOT NULL COMMENT \'Bitset of problem types, default is pass-fail.\'');
$this->addSql('UPDATE problem SET types = 1');
$this->addSql('UPDATE problem SET types = 5 WHERE is_multipass_problem = 1');
$this->addSql('UPDATE problem SET types = 9 WHERE combined_run_compare = 1');
$this->addSql('UPDATE problem SET types = 13 WHERE combined_run_compare = 1 AND is_multipass_problem = 1');
$this->addSql('ALTER TABLE problem DROP combined_run_compare, DROP is_multipass_problem');
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE problem ADD combined_run_compare TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Use the exit code of the run script to compute the verdict\', ADD is_multipass_problem TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Whether this problem is a multi-pass problem.\'');
$this->addSql('UPDATE problem SET combined_run_compare = 1 WHERE types = 9 OR types = 13');
$this->addSql('UPDATE problem SET is_multipass_problem = 1 WHERE types = 5 OR types = 13');
$this->addSql('ALTER TABLE problem DROP types');
}

public function isTransactional(): bool
{
return false;
}
}
2 changes: 1 addition & 1 deletion webapp/src/Controller/Jury/ContestController.php
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,7 @@ public function prefetchAction(Request $request, int $contestId): Response
$runConfig = Utils::jsonEncode(
[
'hash' => $runExec->getHash(),
'combined_run_compare' => $problem->getCombinedRunCompare(),
'combined_run_compare' => $problem->isInteractiveProblem(),
]
);
$judgeTask = new JudgeTask();
Expand Down
22 changes: 4 additions & 18 deletions webapp/src/Controller/Jury/ProblemController.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,10 @@ public function indexAction(): Response
$problemdata['badges'] = ['value' => $badges];

// merge in the rest of the data
$type = '';
if ($p->getCombinedRunCompare()) {
$type .= ' interactive';
}
if ($p->isMultipassProblem()) {
$type .= ' multi-pass (max passes: ' . $p->getMultipassLimit() . ')';
}
$problemdata = array_merge($problemdata, [
'num_contests' => ['value' => (int)($contestCounts[$p->getProbid()] ?? 0)],
'num_testcases' => ['value' => (int)$row['testdatacount']],
'type' => ['value' => $type],
'type' => ['value' => $p->getTypesAsString()],
]);

$data_to_add = [
Expand Down Expand Up @@ -304,7 +297,7 @@ public function exportAction(int $problemId): StreamedResponse
$yaml = ['name' => $problem->getName()];
if (!empty($problem->getCompareExecutable())) {
$yaml['validation'] = 'custom';
} elseif ($problem->getCombinedRunCompare() && !empty($problem->getRunExecutable())) {
} elseif ($problem->isInteractiveProblem() && !empty($problem->getRunExecutable())) {
$yaml['validation'] = 'custom interactive';
}

Expand Down Expand Up @@ -340,7 +333,7 @@ public function exportAction(int $problemId): StreamedResponse
$compareExecutable = null;
if ($problem->getCompareExecutable()) {
$compareExecutable = $problem->getCompareExecutable();
} elseif ($problem->getCombinedRunCompare()) {
} elseif ($problem->isInteractiveProblem()) {
$compareExecutable = $problem->getRunExecutable();
}
if ($compareExecutable) {
Expand Down Expand Up @@ -496,13 +489,6 @@ public function viewAction(Request $request, SubmissionService $submissionServic
page: $request->query->getInt('page', 1),
);

$type = '';
if ($problem->getCombinedRunCompare()) {
$type .= ' interactive';
}
if ($problem->isMultipassProblem()) {
$type .= ' multi-pass (max passes: ' . $problem->getMultipassLimit() . ')';
}
$data = [
'problem' => $problem,
'problemAttachmentForm' => $problemAttachmentForm->createView(),
Expand All @@ -512,7 +498,7 @@ public function viewAction(Request $request, SubmissionService $submissionServic
'defaultOutputLimit' => (int)$this->config->get('output_limit'),
'defaultRunExecutable' => (string)$this->config->get('default_run'),
'defaultCompareExecutable' => (string)$this->config->get('default_compare'),
'type' => $type,
'type' => $problem->getTypesAsString(),
'showContest' => count($this->dj->getCurrentContests(honorCookie: true)) > 1,
'showExternalResult' => $this->dj->shadowMode(),
'lockedProblem' => $lockedProblem,
Expand Down
2 changes: 1 addition & 1 deletion webapp/src/Controller/Jury/SubmissionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ public function viewAction(
'unjudgableReasons' => $unjudgableReasons,
'verificationRequired' => (bool)$this->config->get('verification_required'),
'claimWarning' => $claimWarning,
'combinedRunCompare' => $submission->getProblem()->getCombinedRunCompare(),
'combinedRunCompare' => $submission->getProblem()->isInteractiveProblem(),
'requestedOutputCount' => $requestedOutputCount,
'version_warnings' => [],
'isMultiPassProblem' => $submission->getProblem()->isMultipassProblem(),
Expand Down
103 changes: 83 additions & 20 deletions webapp/src/Entity/Problem.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,6 @@ class Problem extends BaseApiEntity implements
#[Serializer\Exclude]
private ?string $special_compare_args = null;

#[ORM\Column(options: [
'comment' => 'Use the exit code of the run script to compute the verdict',
'default' => 0,
])]
#[Serializer\Exclude]
private bool $combined_run_compare = false;

#[Assert\File]
#[Serializer\Exclude]
private ?UploadedFile $problemstatementFile = null;
Expand All @@ -108,12 +101,24 @@ class Problem extends BaseApiEntity implements
#[Serializer\Exclude]
private ?string $problemstatement_type = null;

#[ORM\Column(options: [
'comment' => 'Whether this problem is a multi-pass problem.',
'default' => 0,
])]
// These types are encoded as bitset - if you add a new type, use the next power of 2.
public const TYPE_PASS_FAIL = 1;
public const TYPE_SCORING = 2;
public const TYPE_MULTI_PASS = 4;
public const TYPE_INTERACTIVE = 8;
public const TYPE_SUBMIT_ANSWER = 16;

private array $typesToString = [
self::TYPE_PASS_FAIL => 'pass-fail',
self::TYPE_SCORING => 'scoring',
self::TYPE_MULTI_PASS => 'multi-pass',
self::TYPE_INTERACTIVE => 'interactive',
self::TYPE_SUBMIT_ANSWER => 'submit-answer',
];

#[ORM\Column(options: ['comment' => 'Bitmask of problem types, default is pass-fail.'])]
#[Serializer\Exclude]
private bool $isMultipassProblem = false;
private int $types = self::TYPE_PASS_FAIL;

#[ORM\Column(
nullable: true,
Expand Down Expand Up @@ -287,26 +292,84 @@ public function getSpecialCompareArgs(): ?string
return $this->special_compare_args;
}

public function setCombinedRunCompare(bool $combinedRunCompare): Problem
public function setTypesAsString(array $types): Problem
{
$this->combined_run_compare = $combinedRunCompare;
$stringToTypes = array_flip($this->typesToString);
$typeConstants = [];
foreach ($types as $type) {
if (!isset($stringToTypes[$type])) {
throw new Exception("Unknown problem type: '$type', must be one of " . implode(', ', array_keys($stringToTypes)));
}
$typeConstants[$type] = $stringToTypes[$type];
}
$this->setTypes($typeConstants);

return $this;
}

public function getCombinedRunCompare(): bool
public function getTypesAsString(): string
{
$typeConstants = $this->getTypes();
$typeStrings = [];
foreach ($typeConstants as $type) {
if (!isset($this->typesToString[$type])) {
throw new Exception("Unknown problem type: '$type'");
}
$typeStrings[] = $this->typesToString[$type];
}
return implode(', ', $typeStrings);
}

public function getTypes(): array
{
return $this->combined_run_compare;
$ret = [];
foreach (array_keys($this->typesToString) as $type) {
if ($this->types & $type) {
$ret[] = $type;
}
}
return $ret;
}

public function setMultipassProblem(bool $isMultipassProblem): Problem
public function setTypes(array $types): Problem
{
$this->isMultipassProblem = $isMultipassProblem;
$types = array_unique($types);
$this->types = 0;
foreach ($types as $type) {
$this->types |= $type;
}
if (!($this->types & self::TYPE_PASS_FAIL) xor ($this->types & self::TYPE_SCORING)) {
throw new Exception("Invalid problem type: must be exactly one of 'pass-fail' or 'scoring'.");
}
if ($this->types & self::TYPE_SUBMIT_ANSWER) {
if ($this->types & self::TYPE_MULTI_PASS) {
throw new Exception("Invalid problem type: 'submit-answer' and 'multi-pass' are mutually exclusive.");
}
if ($this->types & self::TYPE_INTERACTIVE) {
throw new Exception("Invalid problem type: 'submit-answer' and 'interactive' are mutually exclusive.");
}
}
return $this;
}

public function isInteractiveProblem(): bool
{
return (bool)($this->types & self::TYPE_INTERACTIVE);
}

public function isMultipassProblem(): bool
{
return $this->isMultipassProblem;
return (bool)($this->types & self::TYPE_MULTI_PASS);
}

public function isPassFailProblem(): bool
{
return (bool)($this->types & self::TYPE_PASS_FAIL);
}

public function isScoringProblem(): bool
{
return (bool)($this->types & self::TYPE_SCORING);
}

public function setMultipassLimit(?int $multipassLimit): Problem
Expand All @@ -317,7 +380,7 @@ public function setMultipassLimit(?int $multipassLimit): Problem

public function getMultipassLimit(): int
{
if ($this->isMultipassProblem) {
if ($this->isMultipassProblem()) {
return $this->multipassLimit ?? 2;
}
return 1;
Expand Down
18 changes: 11 additions & 7 deletions webapp/src/Form/Type/ProblemType.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
Expand Down Expand Up @@ -78,13 +79,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'label' => 'Compare script arguments',
'required' => false,
]);
$builder->add('combinedRunCompare', CheckboxType::class, [
'label' => 'Use run script as compare script.',
'required' => false,
]);
$builder->add('multipassProblem', CheckboxType::class, [
'label' => 'Multi-pass problem',
'required' => false,
$builder->add('types', ChoiceType::class, [
'choices' => [
'pass-fail' => Problem::TYPE_PASS_FAIL,
'interactive' => Problem::TYPE_INTERACTIVE,
'multipass' => Problem::TYPE_MULTI_PASS,
'scoring' => Problem::TYPE_SCORING,
'submit-answer' => Problem::TYPE_SUBMIT_ANSWER,
],
'multiple' => true,
'required' => true,
]);
$builder->add('multipassLimit', IntegerType::class, [
'label' => 'Multi-pass limit',
Expand Down
6 changes: 3 additions & 3 deletions webapp/src/Service/DOMJudgeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,7 @@ public function printFile(
*/
public function getSamplesZipContent(ContestProblem $contestProblem): string
{
if ($contestProblem->getProblem()->getCombinedRunCompare()) {
if ($contestProblem->getProblem()->isInteractiveProblem()) {
throw new NotFoundHttpException(sprintf('Problem p%d has no downloadable samples', $contestProblem->getProbid()));
}

Expand Down Expand Up @@ -892,7 +892,7 @@ public function getSamplesZipForContest(Contest $contest): StreamedResponse
/** @var ContestProblem $problem */
foreach ($contest->getProblems() as $problem) {
// We don't include the samples for interactive problems.
if (!$problem->getProblem()->getCombinedRunCompare()) {
if (!$problem->getProblem()->isInteractiveProblem()) {
$this->addSamplesToZip($zip, $problem, $problem->getShortname());
}

Expand Down Expand Up @@ -1452,7 +1452,7 @@ public function getCompareConfig(ContestProblem $problem): string
'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()->getCombinedRunCompare(),
'combined_run_compare' => $problem->getProblem()->isInteractiveProblem(),
'hash' => $compareExecutable->getHash(),
]
);
Expand Down
24 changes: 20 additions & 4 deletions webapp/src/Service/ImportProblemService.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,10 @@ public function importZippedProblem(
// The same holds for the timelimit of the problem.
if ($problem->getProbid()) {
$problem
->setTypesAsString(['pass-fail'])
->setCompareExecutable()
->setSpecialCompareArgs('')
->setRunExecutable()
->setCombinedRunCompare(false)
->setMemlimit(null)
->setOutputlimit(null)
->setProblemStatementContent(null)
Expand Down Expand Up @@ -277,6 +277,15 @@ public function importZippedProblem(
$yamlProblemProperties['name'] = $yamlData['name'];
}
}

if (isset($yamlData['type'])) {
$types = explode(' ', $yamlData['type']);
// Validation happens later when we set the properties.
$yamlProblemProperties['typesAsString'] = $types;
} else {
$yamlProblemProperties['typesAsString'] = ['pass-fail'];
}

if (isset($yamlData['validator_flags'])) {
$yamlProblemProperties['special_compare_args'] = $yamlData['validator_flags'];
}
Expand All @@ -290,7 +299,10 @@ public function importZippedProblem(
}

if ($yamlData['validation'] == 'custom multi-pass') {
$problem->setMultipassProblem(true);
$yamlProblemProperties['typesAsString'][] = 'multi-pass';
}
if ($yamlData['validation'] == 'custom interactive') {
$yamlProblemProperties['typesAsString'][] = 'interactive';
}
}

Expand All @@ -307,7 +319,12 @@ public function importZippedProblem(
}

foreach ($yamlProblemProperties as $key => $value) {
$propertyAccessor->setValue($problem, $key, $value);
try {
$propertyAccessor->setValue($problem, $key, $value);
} catch (Exception $e) {
$messages['danger'][] = sprintf('Error: problem.%s: %s', $key, $e->getMessage());
return null;
}
}
}
}
Expand Down Expand Up @@ -1020,7 +1037,6 @@ private function searchAndAddValidator(ZipArchive $zip, ?array &$messages, strin
$this->em->persist($executable);

if ($combinedRunCompare) {
$problem->setCombinedRunCompare(true);
$problem->setRunExecutable($executable);
} else {
$problem->setCompareExecutable($executable);
Expand Down
2 changes: 1 addition & 1 deletion webapp/templates/jury/problem.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
{% endif %}
</td>
</tr>
{% if problem.combinedRunCompare %}
{% if problem.isInteractiveProblem %}
<tr>
<th>Compare script</th>
<td>Run script combines run and compare script.</td>
Expand Down
Loading
Loading