Skip to content

Commit a59ab57

Browse files
committedApr 6, 2025·
Allow selecting new problem types as part of the problem entity.
While you can select the new types, they won't function yet. Part of #2525 Problem types are defined here: https://icpc.io/problem-package-format/spec/2023-07-draft.html#type
1 parent 1530b1a commit a59ab57

File tree

12 files changed

+176
-61
lines changed

12 files changed

+176
-61
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Auto-generated Migration: Please modify to your needs!
12+
*/
13+
final class Version20250323190305 extends AbstractMigration
14+
{
15+
public function getDescription(): string
16+
{
17+
return '';
18+
}
19+
20+
public function up(Schema $schema): void
21+
{
22+
// this up() migration is auto-generated, please modify it to your needs
23+
$this->addSql('ALTER TABLE problem ADD types INT NOT NULL COMMENT \'Bitset of problem types, default is pass-fail.\'');
24+
$this->addSql('UPDATE problem SET types = 1');
25+
$this->addSql('UPDATE problem SET types = 5 WHERE is_multipass_problem = 1');
26+
$this->addSql('UPDATE problem SET types = 9 WHERE combined_run_compare = 1');
27+
$this->addSql('UPDATE problem SET types = 13 WHERE combined_run_compare = 1 AND is_multipass_problem = 1');
28+
$this->addSql('ALTER TABLE problem DROP combined_run_compare, DROP is_multipass_problem');
29+
}
30+
31+
public function down(Schema $schema): void
32+
{
33+
// this down() migration is auto-generated, please modify it to your needs
34+
$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.\'');
35+
$this->addSql('UPDATE problem SET combined_run_compare = 1 WHERE types = 9 OR types = 13');
36+
$this->addSql('UPDATE problem SET is_multipass_problem = 1 WHERE types = 5 OR types = 13');
37+
$this->addSql('ALTER TABLE problem DROP types');
38+
}
39+
40+
public function isTransactional(): bool
41+
{
42+
return false;
43+
}
44+
}

‎webapp/src/Controller/Jury/ContestController.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,7 @@ public function prefetchAction(Request $request, int $contestId): Response
714714
$runConfig = Utils::jsonEncode(
715715
[
716716
'hash' => $runExec->getHash(),
717-
'combined_run_compare' => $problem->getCombinedRunCompare(),
717+
'combined_run_compare' => $problem->isInteractiveProblem(),
718718
]
719719
);
720720
$judgeTask = new JudgeTask();

‎webapp/src/Controller/Jury/ProblemController.php

+4-18
Original file line numberDiff line numberDiff line change
@@ -204,17 +204,10 @@ public function indexAction(): Response
204204
$problemdata['badges'] = ['value' => $badges];
205205

206206
// merge in the rest of the data
207-
$type = '';
208-
if ($p->getCombinedRunCompare()) {
209-
$type .= ' interactive';
210-
}
211-
if ($p->isMultipassProblem()) {
212-
$type .= ' multi-pass (max passes: ' . $p->getMultipassLimit() . ')';
213-
}
214207
$problemdata = array_merge($problemdata, [
215208
'num_contests' => ['value' => (int)($contestCounts[$p->getProbid()] ?? 0)],
216209
'num_testcases' => ['value' => (int)$row['testdatacount']],
217-
'type' => ['value' => $type],
210+
'type' => ['value' => $p->getTypesAsString()],
218211
]);
219212

220213
$data_to_add = [
@@ -304,7 +297,7 @@ public function exportAction(int $problemId): StreamedResponse
304297
$yaml = ['name' => $problem->getName()];
305298
if (!empty($problem->getCompareExecutable())) {
306299
$yaml['validation'] = 'custom';
307-
} elseif ($problem->getCombinedRunCompare() && !empty($problem->getRunExecutable())) {
300+
} elseif ($problem->isInteractiveProblem() && !empty($problem->getRunExecutable())) {
308301
$yaml['validation'] = 'custom interactive';
309302
}
310303

@@ -340,7 +333,7 @@ public function exportAction(int $problemId): StreamedResponse
340333
$compareExecutable = null;
341334
if ($problem->getCompareExecutable()) {
342335
$compareExecutable = $problem->getCompareExecutable();
343-
} elseif ($problem->getCombinedRunCompare()) {
336+
} elseif ($problem->isInteractiveProblem()) {
344337
$compareExecutable = $problem->getRunExecutable();
345338
}
346339
if ($compareExecutable) {
@@ -496,13 +489,6 @@ public function viewAction(Request $request, SubmissionService $submissionServic
496489
page: $request->query->getInt('page', 1),
497490
);
498491

499-
$type = '';
500-
if ($problem->getCombinedRunCompare()) {
501-
$type .= ' interactive';
502-
}
503-
if ($problem->isMultipassProblem()) {
504-
$type .= ' multi-pass (max passes: ' . $problem->getMultipassLimit() . ')';
505-
}
506492
$data = [
507493
'problem' => $problem,
508494
'problemAttachmentForm' => $problemAttachmentForm->createView(),
@@ -512,7 +498,7 @@ public function viewAction(Request $request, SubmissionService $submissionServic
512498
'defaultOutputLimit' => (int)$this->config->get('output_limit'),
513499
'defaultRunExecutable' => (string)$this->config->get('default_run'),
514500
'defaultCompareExecutable' => (string)$this->config->get('default_compare'),
515-
'type' => $type,
501+
'type' => $problem->getTypesAsString(),
516502
'showContest' => count($this->dj->getCurrentContests(honorCookie: true)) > 1,
517503
'showExternalResult' => $this->dj->shadowMode(),
518504
'lockedProblem' => $lockedProblem,

‎webapp/src/Controller/Jury/SubmissionController.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,7 @@ public function viewAction(
577577
'unjudgableReasons' => $unjudgableReasons,
578578
'verificationRequired' => (bool)$this->config->get('verification_required'),
579579
'claimWarning' => $claimWarning,
580-
'combinedRunCompare' => $submission->getProblem()->getCombinedRunCompare(),
580+
'combinedRunCompare' => $submission->getProblem()->isInteractiveProblem(),
581581
'requestedOutputCount' => $requestedOutputCount,
582582
'version_warnings' => [],
583583
'isMultiPassProblem' => $submission->getProblem()->isMultipassProblem(),

‎webapp/src/Entity/Problem.php

+83-20
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,6 @@ class Problem extends BaseApiEntity implements
8686
#[Serializer\Exclude]
8787
private ?string $special_compare_args = null;
8888

89-
#[ORM\Column(options: [
90-
'comment' => 'Use the exit code of the run script to compute the verdict',
91-
'default' => 0,
92-
])]
93-
#[Serializer\Exclude]
94-
private bool $combined_run_compare = false;
95-
9689
#[Assert\File]
9790
#[Serializer\Exclude]
9891
private ?UploadedFile $problemstatementFile = null;
@@ -108,12 +101,24 @@ class Problem extends BaseApiEntity implements
108101
#[Serializer\Exclude]
109102
private ?string $problemstatement_type = null;
110103

111-
#[ORM\Column(options: [
112-
'comment' => 'Whether this problem is a multi-pass problem.',
113-
'default' => 0,
114-
])]
104+
// These types are encoded as bitset - if you add a new type, use the next power of 2.
105+
public const TYPE_PASS_FAIL = 1;
106+
public const TYPE_SCORING = 2;
107+
public const TYPE_MULTI_PASS = 4;
108+
public const TYPE_INTERACTIVE = 8;
109+
public const TYPE_SUBMIT_ANSWER = 16;
110+
111+
private array $typesToString = [
112+
self::TYPE_PASS_FAIL => 'pass-fail',
113+
self::TYPE_SCORING => 'scoring',
114+
self::TYPE_MULTI_PASS => 'multi-pass',
115+
self::TYPE_INTERACTIVE => 'interactive',
116+
self::TYPE_SUBMIT_ANSWER => 'submit-answer',
117+
];
118+
119+
#[ORM\Column(options: ['comment' => 'Bitmask of problem types, default is pass-fail.'])]
115120
#[Serializer\Exclude]
116-
private bool $isMultipassProblem = false;
121+
private int $types = self::TYPE_PASS_FAIL;
117122

118123
#[ORM\Column(
119124
nullable: true,
@@ -287,26 +292,84 @@ public function getSpecialCompareArgs(): ?string
287292
return $this->special_compare_args;
288293
}
289294

290-
public function setCombinedRunCompare(bool $combinedRunCompare): Problem
295+
public function setTypesAsString(array $types): Problem
291296
{
292-
$this->combined_run_compare = $combinedRunCompare;
297+
$stringToTypes = array_flip($this->typesToString);
298+
$typeConstants = [];
299+
foreach ($types as $type) {
300+
if (!isset($stringToTypes[$type])) {
301+
throw new Exception("Unknown problem type: '$type', must be one of " . implode(', ', array_keys($stringToTypes)));
302+
}
303+
$typeConstants[$type] = $stringToTypes[$type];
304+
}
305+
$this->setTypes($typeConstants);
306+
293307
return $this;
294308
}
295309

296-
public function getCombinedRunCompare(): bool
310+
public function getTypesAsString(): string
311+
{
312+
$typeConstants = $this->getTypes();
313+
$typeStrings = [];
314+
foreach ($typeConstants as $type) {
315+
if (!isset($this->typesToString[$type])) {
316+
throw new Exception("Unknown problem type: '$type'");
317+
}
318+
$typeStrings[] = $this->typesToString[$type];
319+
}
320+
return implode(', ', $typeStrings);
321+
}
322+
323+
public function getTypes(): array
297324
{
298-
return $this->combined_run_compare;
325+
$ret = [];
326+
foreach (array_keys($this->typesToString) as $type) {
327+
if ($this->types & $type) {
328+
$ret[] = $type;
329+
}
330+
}
331+
return $ret;
299332
}
300333

301-
public function setMultipassProblem(bool $isMultipassProblem): Problem
334+
public function setTypes(array $types): Problem
302335
{
303-
$this->isMultipassProblem = $isMultipassProblem;
336+
$types = array_unique($types);
337+
$this->types = 0;
338+
foreach ($types as $type) {
339+
$this->types |= $type;
340+
}
341+
if (!($this->types & self::TYPE_PASS_FAIL) xor ($this->types & self::TYPE_SCORING)) {
342+
throw new Exception("Invalid problem type: must be exactly one of 'pass-fail' or 'scoring'.");
343+
}
344+
if ($this->types & self::TYPE_SUBMIT_ANSWER) {
345+
if ($this->types & self::TYPE_MULTI_PASS) {
346+
throw new Exception("Invalid problem type: 'submit-answer' and 'multi-pass' are mutually exclusive.");
347+
}
348+
if ($this->types & self::TYPE_INTERACTIVE) {
349+
throw new Exception("Invalid problem type: 'submit-answer' and 'interactive' are mutually exclusive.");
350+
}
351+
}
304352
return $this;
305353
}
306354

355+
public function isInteractiveProblem(): bool
356+
{
357+
return (bool)($this->types & self::TYPE_INTERACTIVE);
358+
}
359+
307360
public function isMultipassProblem(): bool
308361
{
309-
return $this->isMultipassProblem;
362+
return (bool)($this->types & self::TYPE_MULTI_PASS);
363+
}
364+
365+
public function isPassFailProblem(): bool
366+
{
367+
return (bool)($this->types & self::TYPE_PASS_FAIL);
368+
}
369+
370+
public function isScoringProblem(): bool
371+
{
372+
return (bool)($this->types & self::TYPE_SCORING);
310373
}
311374

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

318381
public function getMultipassLimit(): int
319382
{
320-
if ($this->isMultipassProblem) {
383+
if ($this->isMultipassProblem()) {
321384
return $this->multipassLimit ?? 2;
322385
}
323386
return 1;

‎webapp/src/Form/Type/ProblemType.php

+11-7
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Doctrine\ORM\EntityRepository;
99
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
1010
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
11+
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
1112
use Symfony\Component\Form\Extension\Core\Type\FileType;
1213
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
1314
use Symfony\Component\Form\Extension\Core\Type\NumberType;
@@ -78,13 +79,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
7879
'label' => 'Compare script arguments',
7980
'required' => false,
8081
]);
81-
$builder->add('combinedRunCompare', CheckboxType::class, [
82-
'label' => 'Use run script as compare script.',
83-
'required' => false,
84-
]);
85-
$builder->add('multipassProblem', CheckboxType::class, [
86-
'label' => 'Multi-pass problem',
87-
'required' => false,
82+
$builder->add('types', ChoiceType::class, [
83+
'choices' => [
84+
'pass-fail' => Problem::TYPE_PASS_FAIL,
85+
'interactive' => Problem::TYPE_INTERACTIVE,
86+
'multipass' => Problem::TYPE_MULTI_PASS,
87+
'scoring' => Problem::TYPE_SCORING,
88+
'submit-answer' => Problem::TYPE_SUBMIT_ANSWER,
89+
],
90+
'multiple' => true,
91+
'required' => true,
8892
]);
8993
$builder->add('multipassLimit', IntegerType::class, [
9094
'label' => 'Multi-pass limit',

‎webapp/src/Service/DOMJudgeService.php

+3-3
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,7 @@ public function printFile(
791791
*/
792792
public function getSamplesZipContent(ContestProblem $contestProblem): string
793793
{
794-
if ($contestProblem->getProblem()->getCombinedRunCompare()) {
794+
if ($contestProblem->getProblem()->isInteractiveProblem()) {
795795
throw new NotFoundHttpException(sprintf('Problem p%d has no downloadable samples', $contestProblem->getProbid()));
796796
}
797797

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

@@ -1452,7 +1452,7 @@ public function getCompareConfig(ContestProblem $problem): string
14521452
'script_memory_limit' => $this->config->get('script_memory_limit'),
14531453
'script_filesize_limit' => $this->config->get('script_filesize_limit'),
14541454
'compare_args' => $problem->getProblem()->getSpecialCompareArgs(),
1455-
'combined_run_compare' => $problem->getProblem()->getCombinedRunCompare(),
1455+
'combined_run_compare' => $problem->getProblem()->isInteractiveProblem(),
14561456
'hash' => $compareExecutable->getHash(),
14571457
]
14581458
);

‎webapp/src/Service/ImportProblemService.php

+20-4
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,10 @@ public function importZippedProblem(
204204
// The same holds for the timelimit of the problem.
205205
if ($problem->getProbid()) {
206206
$problem
207+
->setTypesAsString(['pass-fail'])
207208
->setCompareExecutable()
208209
->setSpecialCompareArgs('')
209210
->setRunExecutable()
210-
->setCombinedRunCompare(false)
211211
->setMemlimit(null)
212212
->setOutputlimit(null)
213213
->setProblemStatementContent(null)
@@ -277,6 +277,15 @@ public function importZippedProblem(
277277
$yamlProblemProperties['name'] = $yamlData['name'];
278278
}
279279
}
280+
281+
if (isset($yamlData['type'])) {
282+
$types = explode(' ', $yamlData['type']);
283+
// Validation happens later when we set the properties.
284+
$yamlProblemProperties['typesAsString'] = $types;
285+
} else {
286+
$yamlProblemProperties['typesAsString'] = ['pass-fail'];
287+
}
288+
280289
if (isset($yamlData['validator_flags'])) {
281290
$yamlProblemProperties['special_compare_args'] = $yamlData['validator_flags'];
282291
}
@@ -290,7 +299,10 @@ public function importZippedProblem(
290299
}
291300

292301
if ($yamlData['validation'] == 'custom multi-pass') {
293-
$problem->setMultipassProblem(true);
302+
$yamlProblemProperties['typesAsString'][] = 'multi-pass';
303+
}
304+
if ($yamlData['validation'] == 'custom interactive') {
305+
$yamlProblemProperties['typesAsString'][] = 'interactive';
294306
}
295307
}
296308

@@ -307,7 +319,12 @@ public function importZippedProblem(
307319
}
308320

309321
foreach ($yamlProblemProperties as $key => $value) {
310-
$propertyAccessor->setValue($problem, $key, $value);
322+
try {
323+
$propertyAccessor->setValue($problem, $key, $value);
324+
} catch (Exception $e) {
325+
$messages['danger'][] = sprintf('Error: problem.%s: %s', $key, $e->getMessage());
326+
return null;
327+
}
311328
}
312329
}
313330
}
@@ -1020,7 +1037,6 @@ private function searchAndAddValidator(ZipArchive $zip, ?array &$messages, strin
10201037
$this->em->persist($executable);
10211038

10221039
if ($combinedRunCompare) {
1023-
$problem->setCombinedRunCompare(true);
10241040
$problem->setRunExecutable($executable);
10251041
} else {
10261042
$problem->setCompareExecutable($executable);

‎webapp/templates/jury/problem.html.twig

+1-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
{% endif %}
8787
</td>
8888
</tr>
89-
{% if problem.combinedRunCompare %}
89+
{% if problem.isInteractiveProblem %}
9090
<tr>
9191
<th>Compare script</th>
9292
<td>Run script combines run and compare script.</td>

‎webapp/templates/partials/problem_list.html.twig

+4-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
{% for problem in problems %}
3333
<div class="col">
3434
{% set numsamples = samples[problem.probid] %}
35-
{% if problem.problem.combinedRunCompare %}
35+
{% if problem.problem.interactiveProblem %}
3636
{% set numsamples = 0 %}
3737
{% endif %}
3838
<div class="card">
@@ -55,6 +55,9 @@
5555
{{ ((problem.problem.memlimit | default(defaultMemoryLimit)) * 1024) | printSize }}
5656
</h4>
5757
{% endif %}
58+
<h4 class="card-subtitle mb-2 text-muted">
59+
Type: {{ problem.problem.typesAsString }}
60+
</h4>
5861

5962
{% if stats is defined %}
6063
<div class="mt-3">

‎webapp/tests/Unit/Controller/Jury/ProblemControllerTest.php

+1-3
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ class ProblemControllerTest extends JuryControllerTestCase
4040
'problemstatementFile' => '',
4141
'runExecutable' => 'boolfind_cmp',
4242
'compareExecutable' => '',
43-
'combinedRunCompare' => true,
4443
'specialCompareArgs' => ''],
4544
['name' => '🙃 Unicode in name'],
4645
['name' => 'Long time',
@@ -52,8 +51,7 @@ class ProblemControllerTest extends JuryControllerTestCase
5251
'specialCompareArgs' => 'args'],
5352
['name' => 'Args with Unicode',
5453
'specialCompareArgs' => '🙃 #Should not happen'],
55-
['name' => 'Split Run/Compare',
56-
'combinedRunCompare' => false],
54+
['name' => 'Split Run/Compare'],
5755
['externalid' => '._-3xternal1']];
5856
protected static array $addEntitiesFailure = ['This value should not be blank.' => [['name' => '']],
5957
'Only letters, numbers, dashes, underscores and dots are allowed.' => [['externalid' => 'limited_special_chars!']],

‎webapp/tests/Unit/Controller/Team/ProblemControllerTest.php

+3-2
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ function () use (
8080
$card->filter('h4.card-subtitle')->text(null, true)
8181
);
8282
} else {
83-
static::assertSame(0,
83+
// The problem type is displayed on the page, so we expect one heading.
84+
static::assertSame(1,
8485
$card->filter('h4.card-subtitle')->count());
8586
}
8687

@@ -210,7 +211,7 @@ public function testAccessProblemBeforeContestStarts(): void
210211
$this->client->request('GET', '/public/problems');
211212
static::assertSelectorTextContains('.nav-item .nav-link.disabled', 'Problems');
212213
static::assertSelectorTextContains('.alert.alert-secondary', 'No problem texts available at this point.');
213-
214+
214215
foreach ($probids as $id) {
215216
$endpoints = [
216217
"/team/problems/{$id}",

0 commit comments

Comments
 (0)
Please sign in to comment.