From 809d1ac6ad0f0c2c58089d2bce181bcaa108be1b Mon Sep 17 00:00:00 2001 From: panda Date: Sat, 6 Jun 2026 18:28:33 +0800 Subject: [PATCH] judge: add multi-pass problem support (#1167) --- packages/common/types.ts | 1 + packages/hydrojudge/src/cases.ts | 12 +++++ packages/hydrojudge/src/checkers.ts | 36 +++++++++++++-- .../hydrojudge/src/judge/communication.ts | 2 +- packages/hydrojudge/src/judge/default.ts | 43 +++++++++++++----- packages/hydrojudge/src/judge/interactive.ts | 40 +++++++++++++---- packages/hydrojudge/src/judge/interface.ts | 7 +++ .../components/monaco/schema/problemconfig.ts | 3 +- .../problemconfig/ProblemConfigEditor.tsx | 7 +-- .../problemconfig/ProblemConfigForm.tsx | 45 +++++++++++++++++-- packages/ui-default/locales/zh.yaml | 3 +- 11 files changed, 166 insertions(+), 33 deletions(-) diff --git a/packages/common/types.ts b/packages/common/types.ts index f57c49dc2..d61c7e0a9 100644 --- a/packages/common/types.ts +++ b/packages/common/types.ts @@ -56,6 +56,7 @@ export interface ProblemConfigFile { cases?: TestCaseConfig[]; subtasks?: SubtaskConfig[]; langs?: string[]; + multi_pass?: number; checker?: CompilableSource; interactor?: CompilableSource; manager?: CompilableSource; diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index 3d5d08ba9..773399ec8 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -32,6 +32,18 @@ function isValidConfig(config) { if (!['default', 'strict'].includes(config.checker_type || 'default') && !config.checker) { throw new FormatError('You did not specify a checker.'); } + if (config.type === 'interactive' && !config.interactor) { + throw new FormatError('Interactive problems require an interactor.'); + } + if (config.multi_pass && (!Number.isInteger(config.multi_pass) || config.multi_pass < 2 || config.multi_pass > 20)) { + throw new FormatError('Multi Pass must be between 2 and 20.'); + } + if (config.multi_pass > 1 && !['default', 'interactive'].includes(config.type)) { + throw new FormatError('Multi Pass only supported on default and interactive problems.'); + } + if (config.multi_pass > 1 && config.type === 'default' && !['testlib', 'kattis'].includes(config.checker_type)) { + throw new FormatError('Multi Pass on default problems requires a testlib or kattis checker.'); + } } async function collectFiles(folder: string) { diff --git a/packages/hydrojudge/src/checkers.ts b/packages/hydrojudge/src/checkers.ts index f7a8e09cc..3f450e2f7 100644 --- a/packages/hydrojudge/src/checkers.ts +++ b/packages/hydrojudge/src/checkers.ts @@ -20,6 +20,7 @@ type Checker = (config: CheckConfig) => Promise<{ status: number; score: number; message: string; + nextPass?: { input: CopyInFile, state?: Record }; }>; function parseDiffMsg(msg: string) { @@ -236,7 +237,7 @@ const checkers: Record = new Proxy({ }, async testlib(config) { - const { stderr, status, code } = await runQueued(`${config.execute} /w/in /w/user_out /w/answer`, { + const { stderr, status, code, fileIds } = await runQueued(`${config.execute} /w/in /w/user_out /w/answer`, { copyIn: { in: config.input, user_out: config.user_stdout, @@ -245,6 +246,7 @@ const checkers: Record = new Proxy({ ...config.copyIn, }, env: config.env, + copyOutCached: ['nextpass.in?', 'state.txt?'], }); if ([STATUS.STATUS_SYSTEM_ERROR, STATUS.STATUS_TIME_LIMIT_EXCEEDED, STATUS.STATUS_MEMORY_LIMIT_EXCEEDED].includes(status)) { const message = { @@ -265,12 +267,22 @@ const checkers: Record = new Proxy({ message: `Checker exited with code ${code}`, }; } - return parse(stderr, config.score, config.detail); + const result = parse(stderr, config.score, config.detail); + if (result.status === STATUS.STATUS_ACCEPTED && fileIds['nextpass.in']) { + return { + ...result, + nextPass: { + input: { fileId: fileIds['nextpass.in'] }, + state: fileIds['state.txt'] ? { 'state.txt': { fileId: fileIds['state.txt'] } } : undefined, + }, + }; + } + return result; }, // https://www.kattis.com/problem-package-format/spec/2023-07-draft.html#output-validator async kattis(config) { - const { files, code } = await runQueued(`${config.execute} input answer_file feedback_dir`, { + const { files, fileIds, code } = await runQueued(`${config.execute} input answer_file feedback_dir`, { copyIn: { input: config.input, answer_file: config.output, @@ -284,6 +296,10 @@ const checkers: Record = new Proxy({ 'feedback_dir/teammessage.txt?', 'feedback_dir/judgeerror.txt?', ], + copyOutCached: [ + 'feedback_dir/nextpass.in?', + 'feedback_dir/state.txt?', + ], }); const status = code === 42 @@ -302,6 +318,20 @@ const checkers: Record = new Proxy({ ? files['feedback_dir/teammessage.txt'] || files['feedback_dir/judgemessage.txt'] || '' : ''; + if (status === STATUS.STATUS_ACCEPTED && fileIds['feedback_dir/nextpass.in']) { + return { + status, + score, + message, + nextPass: { + input: { fileId: fileIds['feedback_dir/nextpass.in'] }, + state: fileIds['feedback_dir/state.txt'] + ? { 'feedback_dir/state.txt': { fileId: fileIds['feedback_dir/state.txt'] } } + : undefined, + }, + }; + } + return { status, score, message }; }, }, { diff --git a/packages/hydrojudge/src/judge/communication.ts b/packages/hydrojudge/src/judge/communication.ts index c74bf4f0c..d3bb197b1 100644 --- a/packages/hydrojudge/src/judge/communication.ts +++ b/packages/hydrojudge/src/judge/communication.ts @@ -39,7 +39,7 @@ function judgeCase(c: NormalizedCase) { }); } execute[0].execute += managerArgs; - const res = await runPiped(execute, pipeMapping); + const res = await runPiped(execute, pipeMapping, undefined, `judgeCase[${c.id}]<${ctx.rid}>`); const resManager = res[0]; let time = 0; let memory = 0; diff --git a/packages/hydrojudge/src/judge/default.ts b/packages/hydrojudge/src/judge/default.ts index 31f60e279..0020965fb 100644 --- a/packages/hydrojudge/src/judge/default.ts +++ b/packages/hydrojudge/src/judge/default.ts @@ -3,16 +3,19 @@ import checkers from '../checkers'; import { runFlow } from '../flow'; import { runQueued } from '../sandbox'; import signals from '../signals'; -import { Context, ContextSubTask } from './interface'; +import { Context, ContextSubTask, MultiPassContext } from './interface'; function judgeCase(c: NormalizedCase) { + const mp: MultiPassContext = { i: 0 }; return async (ctx: Context, ctxSubtask: ContextSubTask, runner?: Function) => { const { address_space_limit, process_limit } = ctx.session.getLang(ctx.lang); + if (ctx.config.multi_pass && !mp.i) mp.i = 1; + await using res = await runQueued( ctx.execute.execute, { - stdin: { src: c.input }, - copyIn: ctx.execute.copyIn, + stdin: mp.input ?? { src: c.input }, + copyIn: { ...ctx.execute.copyIn, ...mp.state }, filename: ctx.config.filename, time: c.time, memory: c.memory, @@ -20,25 +23,29 @@ function judgeCase(c: NormalizedCase) { addressSpaceLimit: address_space_limit, processLimit: process_limit, }, - `judgeCase[${c.id}]<${ctx.rid}>`, + `judgeCase[${c.id}]${mp.i ? `[pass=${mp.i}]` : ''}<${ctx.rid}>`, ); const { code, signalled, time, memory, fileIds, } = res; let { status } = res; - let message: any = ''; + let message: any = mp.i ? `[Pass ${mp.i}] ` : ''; let score = 0; + let nextPass: any; if (status === STATUS.STATUS_ACCEPTED) { if (time > c.time) { status = STATUS.STATUS_TIME_LIMIT_EXCEEDED; } else if (memory > c.memory * 1024) { status = STATUS.STATUS_MEMORY_LIMIT_EXCEEDED; } else { - ({ status, score, message } = await checkers[ctx.config.checker_type]({ + const resChecker = await checkers[ctx.config.checker_type]({ execute: ctx.checker.execute, - copyIn: ctx.checker.copyIn || {}, + copyIn: { + ...ctx.checker.copyIn, + ...mp.state ?? {}, + }, code: ctx.code, - input: { src: c.input }, + input: mp.input ?? { src: c.input }, output: { src: c.output }, user_stdout: fileIds.stdout ? { fileId: fileIds.stdout } : { content: '' }, user_stderr: fileIds.stderr ? { fileId: fileIds.stderr } : { content: '' }, @@ -49,16 +56,30 @@ function judgeCase(c: NormalizedCase) { HYDRO_TESTCASE: c.id.toString(), HYDRO_TIME_USAGE: time.toString(), HYDRO_MEMORY_USAGE: Math.floor(memory / 1024).toString(), + ...(mp.i ? { HYDRO_MULTI_PASS: mp.i.toString() } : {}), }, - })); + }); + ({ status, score, nextPass } = resChecker); + message += resChecker.message; } } else if (status === STATUS.STATUS_RUNTIME_ERROR && code && ctx.config.detail === 'full') { if (code < 32 && signalled) message = signals[code]; - else message = { message: 'Your program returned {0}.', params: [code] }; + else message = { message: `${message}Your program returned {0}.`, params: [code] }; + } + if (nextPass) { + if (mp.i < ctx.config.multi_pass) { + mp.input = nextPass.input; + mp.state = nextPass.state ?? undefined; + mp.i++; + return await runner(ctx, ctxSubtask, runner); + } + status = STATUS.STATUS_SYSTEM_ERROR; + score = 0; + message = { message: 'Exceeded maximum number of passes ({0}).', params: [ctx.config.multi_pass] }; } if (runner && ctx.rerun && c.time <= 5000 && status === STATUS.STATUS_TIME_LIMIT_EXCEEDED) { ctx.rerun--; - return await runner(ctx, ctxSubtask); + return await runner(ctx, ctxSubtask, runner); } if (!ctx.request.rejudged && !ctx.analysis && [STATUS.STATUS_WRONG_ANSWER, STATUS.STATUS_RUNTIME_ERROR].includes(status)) { ctx.analysis = true; diff --git a/packages/hydrojudge/src/judge/interactive.ts b/packages/hydrojudge/src/judge/interactive.ts index 0a6b01fbc..892c470ad 100644 --- a/packages/hydrojudge/src/judge/interactive.ts +++ b/packages/hydrojudge/src/judge/interactive.ts @@ -3,17 +3,20 @@ import { runFlow } from '../flow'; import { runPiped } from '../sandbox'; import signals from '../signals'; import { parse } from '../testlib'; -import { Context, ContextSubTask } from './interface'; +import { Context, ContextSubTask, MultiPassContext } from './interface'; function judgeCase(c: NormalizedCase) { - return async (ctx: Context, ctxSubtask: ContextSubTask) => { + const mp: MultiPassContext = { i: 0 }; + return async (ctx: Context, ctxSubtask: ContextSubTask, runner?: Function) => { const { address_space_limit, process_limit } = ctx.session.getLang(ctx.lang); + if (ctx.config.multi_pass && !mp.i) mp.i = 1; + const [{ code, signalled, time, memory, }, resInteractor] = await runPiped([ { execute: ctx.executeUser.execute, - copyIn: ctx.executeUser.copyIn, + copyIn: { ...ctx.executeUser.copyIn, ...mp.state }, time: c.time, memory: c.memory, addressSpaceLimit: address_space_limit, @@ -22,23 +25,29 @@ function judgeCase(c: NormalizedCase) { { execute: `${ctx.executeInteractor.execute} /w/in /w/tout /w/out`, copyIn: { - in: c.input ? { src: c.input } : { content: '' }, + in: mp.input ?? { src: c.input }, out: c.output ? { src: c.output } : { content: '' }, ...ctx.executeInteractor.copyIn, + ...mp.state ?? {}, }, time: c.time * 2, memory: c.memory * 2, copyOut: ['/w/tout?'], - env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() }, + copyOutCached: ['nextpass.in?', 'state.txt?'], + env: { + ...ctx.env, + HYDRO_TESTCASE: c.id.toString(), + ...(mp.i ? { HYDRO_MULTI_PASS: mp.i.toString() } : {}), + }, }, ], [ { in: { index: 0, fd: 1 }, out: { index: 1, fd: 0 }, name: 'userToInteractor' }, { in: { index: 1, fd: 1 }, out: { index: 0, fd: 0 }, name: 'interactorToUser' }, - ]); + ], undefined, `judgeCase[${c.id}]${mp.i ? `[pass=${mp.i}]` : ''}<${ctx.rid}>`); // TODO handle tout (maybe pass to checker?) let status: number; let score = 0; - let message: any = ''; + let message: any = mp.i ? `[Pass ${mp.i}] ` : ''; if (time > c.time) { status = STATUS.STATUS_TIME_LIMIT_EXCEEDED; } else if (memory > c.memory * 1024) { @@ -46,13 +55,26 @@ function judgeCase(c: NormalizedCase) { } else if (ctx.config.detail === 'full' && ((code && code !== 13/* Broken Pipe */) || (code === 13 && !resInteractor.code))) { status = STATUS.STATUS_RUNTIME_ERROR; if (code < 32 && signalled) message = signals[code]; - else message = { message: 'Your program returned {0}.', params: [code] }; + else message = { message: `${message}Your program returned {0}.`, params: [code] }; } else { const result = parse(resInteractor.stderr, c.score, ctx.config.detail); status = result.status; score = result.score; - message = result.message; + message += result.message; if (resInteractor.code && !(resInteractor.stderr || '').trim().length) message += ` (Interactor exited with code ${resInteractor.code})`; + if (status === STATUS.STATUS_ACCEPTED) { + if (resInteractor.fileIds['nextpass.in']) { + if (mp.i < ctx.config.multi_pass) { + mp.input = { fileId: resInteractor.fileIds['nextpass.in'] }; + mp.state = resInteractor.fileIds['state.txt'] ? { 'state.txt': { fileId: resInteractor.fileIds['state.txt'] } } : undefined; + mp.i++; + return await runner!(ctx, ctxSubtask, runner); + } + status = STATUS.STATUS_SYSTEM_ERROR; + score = 0; + message = { message: 'Exceeded maximum number of passes ({0}).', params: [ctx.config.multi_pass] }; + } + } } return { id: c.id, diff --git a/packages/hydrojudge/src/judge/interface.ts b/packages/hydrojudge/src/judge/interface.ts index 0393edb3a..b76732618 100644 --- a/packages/hydrojudge/src/judge/interface.ts +++ b/packages/hydrojudge/src/judge/interface.ts @@ -1,6 +1,7 @@ import PQueue from 'p-queue'; import { NormalizedSubtask } from '@hydrooj/common'; import { Execute } from '../interface'; +import { CopyInFile } from '../sandbox'; import { JudgeTask } from '../task'; export type Context = JudgeTask & RuntimeContext; @@ -29,3 +30,9 @@ export interface ContextSubTask { score: number; status: number; } + +export interface MultiPassContext { + i: number; + input?: CopyInFile; + state?: Record; +} diff --git a/packages/ui-default/components/monaco/schema/problemconfig.ts b/packages/ui-default/components/monaco/schema/problemconfig.ts index 73d00a74f..875551e44 100644 --- a/packages/ui-default/components/monaco/schema/problemconfig.ts +++ b/packages/ui-default/components/monaco/schema/problemconfig.ts @@ -79,7 +79,8 @@ const problemConfigSchema: JSONSchema7 = { }, interactor: { $ref: '#/definitions/compilableFile' }, manager: { $ref: '#/definitions/compilableFile' }, - num_processes: { type: 'number', minimum: 1, maximum: 5 }, + num_processes: { type: 'integer', maximum: 5, minimum: 1 }, + multi_pass: { type: 'integer', maximum: 20, minimum: 2 }, validator: { $ref: '#/definitions/compilableFile' }, user_extra_files: { type: 'array', items: { type: 'string' } }, judge_extra_files: { type: 'array', items: { type: 'string' } }, diff --git a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx index c6f91a65b..ca8672358 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx @@ -29,9 +29,9 @@ interface Props { const configKey = [ 'type', 'subType', 'target', 'score', 'time', 'memory', 'filename', 'checker_type', 'checker', 'interactor', - 'manager', 'num_processes', 'validator', 'user_extra_files', 'judge_extra_files', - 'detail', 'outputs', 'redirect', 'cases', 'subtasks', - 'langs', 'key', 'time_limit_rate', 'memory_limit_rate', + 'manager', 'num_processes', 'multi_pass', 'validator', 'user_extra_files', + 'judge_extra_files', 'detail', 'outputs', 'redirect', 'cases', + 'subtasks', 'langs', 'key', 'time_limit_rate', 'memory_limit_rate', ]; const subtasksKey = [ @@ -50,6 +50,7 @@ export function configYamlFormat(config: ProblemConfigFile) { if (key === 'checker' && (['default', 'strict'].includes(formatConfig.checker_type) || !formatConfig.checker_type)) return; if (key === 'interactor' && config.type !== 'interactive') return; + if (key === 'multi_pass' && (!Number.isInteger(config.multi_pass) || config.multi_pass <= 1 || config.multi_pass > 20)) return; if (key === 'subtasks') { formatConfig[key] = []; config[key].forEach((subtask) => { diff --git a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx index cc9e2516c..41f6b5c31 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -1,6 +1,6 @@ -import { Card, Text, TextInput } from '@mantine/core'; +import { Card, Switch, Text, TextInput } from '@mantine/core'; import { isEqual } from 'lodash'; -import React from 'react'; +import React, { useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { i18n } from 'vj/utils'; import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete'; @@ -13,10 +13,10 @@ function FileIOConfig() { const filename = useSelector((state: RootState) => state.config.filename); const dispatch = useDispatch(); return ( - +
- + .in/.out} rightSectionWidth={100} @@ -34,6 +34,42 @@ function FileIOConfig() { ); } +function MultiPassConfig() { + const multiPass = useSelector((state: RootState) => state.config.multi_pass); + const dispatch = useDispatch(); + const [enabled, setEnabled] = useState(multiPass > 1); + return ( + + +
+ + { + setEnabled(!enabled); + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'multi_pass', value: enabled ? 0 : 2 }); + }} + /> + + {enabled && ( + + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'multi_pass', value: +ev.currentTarget.value })} + /> + + )} +
+
+
+ ); +} + function ExtraFilesConfig() { const Files = useSelector((state: RootState) => state.testdata); const userExtraFiles = useSelector((state: RootState) => state.config.user_extra_files || [], isEqual); @@ -92,6 +128,7 @@ export default function ProblemConfigForm() {
{Type === 'default' && } + {['default', 'interactive'].includes(Type) && } {!['submit_answer', 'objective'].includes(Type) && ( <> diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 489c7c4e3..153729051 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -422,7 +422,6 @@ Forgot Password and/or Username: 忘记密码和/或用户名 Forgot password or username?: 忘记密码或者用户名? Formula blocks: 公式区块 Freeze scoreboard with N minutes remaining: 在赛事剩余N分钟时封榜 -Keep scoreboard hidden even after contest ends: 即使比赛结束后也保持成绩表隐藏 fs_upload: 上传文件 Gender Visibility: 性别可见性 Gender: 性别 @@ -523,6 +522,7 @@ Judged By: 评测机 Judging Queue: 评测队列 just now: 刚刚 Keep current expiration: 保持当前过期设置 +Keep scoreboard hidden even after contest ends: 即使比赛结束后也保持成绩表隐藏 Langs Config: 语言设置 langs: 语言 Language: 语言 @@ -572,6 +572,7 @@ Mark Green: 标绿 Mark Red: 标红 Mark user as joined using admin privilege: 使用管理员权限将用户标记为已加入 Mark Yellow: 标黄 +Max Passes: 最大轮次 Memory Cost: 内存占用 Memory Info: 内存信息 Memory: 内存