judge: add type: submit_answer support

This commit is contained in:
undefined committed 2022-04-05 21:22:57 +08:00
1 parent 476457ac23
commit 20a1542bc3
9 files changed
+210 -46

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@hydrooj/hydrojudge", "name": "@hydrooj/hydrojudge",
"bin": "bin/hydrojudge.js", "bin": "bin/hydrojudge.js",
"version": "2.16.0", "version": "2.17.0",
"main": "package.json", "main": "package.json",
"author": "undefined <i@undefined.moe>", "author": "undefined <i@undefined.moe>",
"repository": "https://github.com/hydro-dev/Hydro.git", "repository": "https://github.com/hydro-dev/Hydro.git",
-1
View File
@@ -185,7 +185,6 @@ async function readAutoCases(folder: string, { next }, cfg, rst) {
} }
function isValidConfig(config) { function isValidConfig(config) {
if (config.type === 'submit_answer' && !config.outputs.length) throw new FormatError('Problem data not found.');
if (config.count > (getConfig('testcases_max') || 100)) { if (config.count > (getConfig('testcases_max') || 100)) {
throw new FormatError('Too many testcases. Cancelled.'); throw new FormatError('Too many testcases. Cancelled.');
} }
+2 -1
View File
@@ -1,9 +1,10 @@
import * as def from './default'; import * as def from './default';
import * as interactive from './interactive'; import * as interactive from './interactive';
import { Context } from './interface'; import { Context } from './interface';
import * as objective from './objective';
import * as run from './run'; import * as run from './run';
import * as submit_answer from './submit_answer'; import * as submit_answer from './submit_answer';
export = { export = {
default: def, interactive, run, submit_answer, objective: submit_answer, default: def, interactive, run, submit_answer, objective,
} as Record<string, { judge(ctx: Context, startPromise?: Promise<any>): Promise<void> }>; } as Record<string, { judge(ctx: Context, startPromise?: Promise<any>): Promise<void> }>;
@@ -0,0 +1,39 @@
import { readFile } from 'fs-extra';
import { STATUS } from '@hydrooj/utils/lib/status';
import { Context } from './interface';
export async function judge({
next, end, config, code,
}: Context) {
next({ status: STATUS.STATUS_JUDGING, progress: 0 });
const answer = ('src' in code)
? await readFile(code.src, 'utf-8')
: ('content' in code)
? code.content.toString().replace(/\r/g, '')
: '';
const outputs = answer.split('\n');
let totalScore = 0;
let totalStatus = 0;
for (const i in config.outputs) {
const c = config.outputs[i];
outputs[i] = outputs[i] || '';
let status = STATUS.STATUS_WRONG_ANSWER;
let score = 0;
if (outputs[i].trim() === (c.output || c[0]).trim()) {
score = c.score || c[1];
status = STATUS.STATUS_ACCEPTED;
}
totalScore += score;
totalStatus = Math.max(status, totalStatus);
next({
status: totalStatus,
progress: (100 * (+i + 1)) / config.outputs.length,
case: {
status, score, time_ms: 0, memory_kb: 0, message: '',
},
}, +i + 1);
}
return end({
status: totalStatus, score: totalScore, time_ms: 0, memory_kb: 0,
});
}
+151 -36
View File
@@ -1,41 +1,156 @@
import { readFile } from 'fs-extra'; import Queue from 'p-queue';
import { STATUS } from '@hydrooj/utils/lib/status'; import { STATUS } from '@hydrooj/utils/lib/status';
import { Context } from './interface'; import { check, compileChecker } from '../check';
import { getConfig } from '../config';
import { FormatError } from '../error';
import { del, run } from '../sandbox';
import { parseFilename } from '../utils';
import {
Case, Context, ContextSubTask, SubTask,
} from './interface';
// TODO drop this format in next major version const Score = {
// NOT RECOMMENDED TO USE sum: (a: number, b: number) => (a + b),
export async function judge({ max: Math.max,
next, end, config, code, min: Math.min,
}: Context) { };
next({ status: STATUS.STATUS_JUDGING, progress: 0 });
const answer = ('src' in code) function judgeCase(c: Case, sid: string) {
? await readFile(code.src, 'utf-8') return async (ctx: Context, ctxSubtask: ContextSubTask) => {
: ('content' in code) if (ctx.errored || (ctx.failed[sid] && ctxSubtask.subtask.type === 'min')
? code.content.toString().replace(/\r/g, '') || (ctxSubtask.subtask.type === 'max' && ctxSubtask.score === ctxSubtask.subtask.score)
: ''; || ((ctxSubtask.subtask.if || []).filter((i: string) => ctx.failed[i]).length)
const outputs = answer.split('\n'); ) {
let totalScore = 0; ctx.next({
let totalStatus = 0; case: {
for (const i in config.outputs) { status: STATUS.STATUS_CANCELED,
const c = config.outputs[i]; score: 0,
outputs[i] = outputs[i] || ''; time_ms: 0,
let status = STATUS.STATUS_WRONG_ANSWER; memory_kb: 0,
let score = 0; message: '',
if (outputs[i].trim() === (c.output || c[0]).trim()) { },
score = c.score || c[1]; addProgress: 100 / ctx.config.count,
status = STATUS.STATUS_ACCEPTED; }, c.id);
return;
} }
totalScore += score;
totalStatus = Math.max(status, totalStatus); const res = await run(
next({ ctx.config.subType === 'multi'
status: totalStatus, ? '/bin/bash -c "unzip foo.zip >/dev/null && read line && cat $line"'
progress: (100 * (+i + 1)) / config.outputs.length, : '/bin/cat foo.zip',
case: { {
status, score, time_ms: 0, memory_kb: 0, message: '', stdin: c.input,
copyIn: { 'foo.zip': ctx.code },
copyOutCached: [],
time: 1000,
memory: 128,
cacheStdoutAndStderr: true,
}, },
}, +i + 1); );
} const { code } = res;
return end({ let { status } = res;
status: totalStatus, score: totalScore, time_ms: 0, memory_kb: 0, let message: any = '';
}); let score = 0;
if (status === STATUS.STATUS_ACCEPTED) {
[status, score, message] = await check({
execute: ctx.checker.execute,
copyIn: ctx.checker.copyIn || {},
input: { src: c.input },
output: { src: c.output },
user_stdout: { fileId: res.fileIds['stdout'] },
user_stderr: { fileId: res.fileIds['stderr'] },
checker_type: ctx.config.checker_type,
score: ctxSubtask.subtask.score,
detail: ctx.config.detail ?? true,
env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() },
});
} else if (status === STATUS.STATUS_RUNTIME_ERROR && code) {
message = { message: 'Unzip failed or file not found.' };
}
await Promise.all(
Object.values(res.fileIds).map((id) => del(id)),
).catch(() => { /* Ignore file doesn't exist */ });
ctxSubtask.score = Score[ctxSubtask.subtask.type](ctxSubtask.score, score);
ctxSubtask.status = Math.max(ctxSubtask.status, status);
if (ctxSubtask.status > STATUS.STATUS_ACCEPTED) ctx.failed[sid] = true;
ctx.next({
case: {
status,
score: 0,
time_ms: 0,
memory_kb: 0,
message,
},
addProgress: 100 / ctx.config.count,
}, c.id);
};
} }
function judgeSubtask(subtask: SubTask, sid: string) {
return async (ctx: Context) => {
subtask.type = subtask.type || 'min';
const ctxSubtask = {
subtask,
status: 0,
score: subtask.type === 'min'
? subtask.score
: 0,
};
const cases = [];
for (const cid in subtask.cases) {
const runner = judgeCase(subtask.cases[cid], sid);
cases.push(ctx.queue.add(() => runner(ctx, ctxSubtask)));
}
await Promise.all(cases).catch((e) => {
ctx.errored = true;
throw e;
});
ctx.total_status = Math.max(ctx.total_status, ctxSubtask.status);
return ctxSubtask.score;
};
}
export const judge = async (ctx: Context, startPromise = Promise.resolve()) => {
if (!ctx.config.subtasks.length) throw new FormatError('Problem data not found.');
startPromise.then(() => ctx.next({ status: STATUS.STATUS_COMPILING }));
ctx.checker = await (() => {
if (['default', 'strict'].includes(ctx.config.checker_type || 'default')) {
return { execute: '', copyIn: {}, clean: () => Promise.resolve(null) };
}
const copyIn = { user_code: ctx.code };
for (const file of ctx.config.judge_extra_files) {
copyIn[parseFilename(file)] = { src: file };
}
return compileChecker(
ctx.getLang,
ctx.config.checker_type,
ctx.config.checker,
copyIn,
);
})();
ctx.clean.push(ctx.checker.clean);
await startPromise;
ctx.next({ status: STATUS.STATUS_JUDGING, progress: 0 });
const tasks = [];
ctx.total_status = 0;
ctx.total_score = 0;
ctx.queue = new Queue({ concurrency: getConfig('parallelism') });
ctx.failed = {};
for (const sid in ctx.config.subtasks) tasks.push(judgeSubtask(ctx.config.subtasks[sid], sid)(ctx));
const scores = await Promise.all(tasks);
for (const sid in ctx.config.subtasks) {
let effective = true;
for (const required of ctx.config.subtasks[sid].if || []) {
if (ctx.failed[required.toString()]) effective = false;
}
if (effective) ctx.total_score += scores[sid];
}
ctx.stat.done = new Date();
if (process.env.DEV) ctx.next({ message: JSON.stringify(ctx.stat) });
ctx.end({
status: ctx.total_status,
score: ctx.total_score,
time_ms: 0,
memory_kb: 0,
});
};
+1
View File
@@ -26,6 +26,7 @@
"fs-extra": "^10.0.1", "fs-extra": "^10.0.1",
"graphql": "^16.3.0", "graphql": "^16.3.0",
"graphql-scalars": "1.17.0", "graphql-scalars": "1.17.0",
"isbinaryfile": "^5.0.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"koa": "^2.13.4", "koa": "^2.13.4",
"koa-body": "^4.2.0", "koa-body": "^4.2.0",
+14 -5
View File
@@ -1,5 +1,6 @@
import AdmZip from 'adm-zip'; import AdmZip from 'adm-zip';
import { statSync } from 'fs-extra'; import { readFile, statSync } from 'fs-extra';
import { isBinaryFile } from 'isbinaryfile';
import { intersection, isSafeInteger } from 'lodash'; import { intersection, isSafeInteger } from 'lodash';
import { FilterQuery, ObjectID } from 'mongodb'; import { FilterQuery, ObjectID } from 'mongodb';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
@@ -481,10 +482,18 @@ export class ProblemSubmitHandler extends ProblemDetailHandler {
if (!code) { if (!code) {
const file = this.request.files?.file; const file = this.request.files?.file;
if (!file) throw new ValidationError('code'); if (!file) throw new ValidationError('code');
if (file.size > 128 * 1024 * 1024) throw new ValidationError('file'); const config = this.response.body.pdoc.config;
const id = nanoid(); if (typeof config !== 'object' || config === null) throw new BadRequestError('Incorrect problem config');
await storage.put(`submission/${this.user._id}/${id}`, file.path); const sizeLimit = config.type === 'submit_answer' ? 128 * 1024 * 1024 : 65535;
code = `@@hydro_submission_file@@${this.user._id}/${id}#${file.name}`; if (file.size > sizeLimit) throw new ValidationError('file');
if (file.size > 65535 || await isBinaryFile(file.path, file.size)) {
const id = nanoid();
await storage.put(`submission/${this.user._id}/${id}`, file.path);
code = `@@hydro_submission_file@@${this.user._id}/${id}#${file.name}`;
} else {
// TODO auto detect & convert encoding
code = await readFile(file.path, 'utf-8');
}
} else if (code.startsWith('@@hydro_submission_file@@')) throw new ValidationError('code'); } else if (code.startsWith('@@hydro_submission_file@@')) throw new ValidationError('code');
await this.limitRate('add_record', 60, system.get('limit.submission')); await this.limitRate('add_record', 60, system.get('limit.submission'));
const rid = await record.add(domainId, this.pdoc.docId, this.user._id, lang, code, true, pretest ? input : tid, tid && !pretest); const rid = await record.add(domainId, this.pdoc.docId, this.user._id, lang, code, true, pretest ? input : tid, tid && !pretest);
+1 -1
View File
@@ -76,7 +76,7 @@ export default async function readYamlCases(cfg: Record<string, any> = {}, check
} }
if (cfg.time) config.time = parseTimeMS(cfg.time); if (cfg.time) config.time = parseTimeMS(cfg.time);
if (cfg.memory) config.memory = parseMemoryMB(cfg.memory); if (cfg.memory) config.memory = parseMemoryMB(cfg.memory);
if ((config.type === 'objective' || cfg.type === 'submit_answer') && !cfg.outputs?.length) throw new Error('outputs config not found'); if (config.type === 'objective' && !cfg.outputs?.length) throw new Error('outputs config not found');
return Object.assign(cfg, config); return Object.assign(cfg, config);
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@hydrooj/utils", "name": "@hydrooj/utils",
"version": "1.1.16", "version": "1.1.17",
"description": "hydrooj utils", "description": "hydrooj utils",
"main": "package.json", "main": "package.json",
"repository": "https://github.com/hydro-dev/Hydro.git", "repository": "https://github.com/hydro-dev/Hydro.git",