feat: 任务书 05,runner 与 tasks 两个模块
runner 用 Docker 跑一条命令并交回结局和资源用量;tasks 读 task.json、准备运行目录、给出正式运行/校验器/轻量检查的运行定义,并从原始产物独立判分。任务目录从参考项目搬到 tasks/circle_packing/,新增 task.json;vocab 的 VERDICTS 删掉"判不了",ProcessResult 的 cancelled 改为"容器从外面被杀了"。 全新上下文审阅后的两处修复:计量 shell 以容器内 root 运行,命令经 setpriv 降权,结果写在容器自己的文件层、结束后 docker cp 取出,并加 no-new-privileges,候选无法伪造计量结果把超时记成正常退出;harness 开跑前删掉旧的 solution.json、metrics.json,旧产物不会被当成本次的值。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
import { copyFileSync, cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Type, type Static } from "typebox";
|
||||
import { Value } from "typebox/value";
|
||||
import type {
|
||||
HypothesisMetric,
|
||||
Json,
|
||||
JudgedResult,
|
||||
Measured,
|
||||
MetricDefinition,
|
||||
ProcessResult,
|
||||
RunDefinition,
|
||||
} from "../vocab/index.ts";
|
||||
import type { TaskFacts } from "./facts.ts";
|
||||
import { measured, readMetricsFile, unmeasured } from "./metrics-file.ts";
|
||||
import type { LightCheckOutcome, Task, TaskImplementation } from "./types.ts";
|
||||
|
||||
// 填圆:在单位正方形里放 26 个圆,最大化半径之和。任务说明见 tasks/circle_packing/TASK.md。
|
||||
// 凡是要执行任务代码的事(正式运行、校验器、轻量检查)都只交出运行定义,由调用方交给 runner 在任务镜像里跑;
|
||||
// 这里只读写文件、解读产物(docs/结构.md 第二部分"runner 只管怎么跑,tasks 只管怎么判")。
|
||||
|
||||
const CIRCLES = 26;
|
||||
const ENTRY = "initial.py";
|
||||
const HARNESS = "harness.py";
|
||||
const VALIDATOR = "validate.py";
|
||||
const CHECK_SCRIPT = "check-candidate.py";
|
||||
const METRICS_SPEC = "metrics-spec.json";
|
||||
const METRICS = "metrics.json";
|
||||
const SOLUTION = "solution.json";
|
||||
const VERDICT = "verdict.json";
|
||||
const CHECK_RESULT = "check.json";
|
||||
/** 轻量检查目录里放候选代码的子目录;检查脚本放在它的上一层,免得扫到自己。 */
|
||||
const CHECK_CANDIDATE = "candidate";
|
||||
/** 运行目录挂进容器的位置。 */
|
||||
const WORKDIR = "/work";
|
||||
/** 校验器的时限。26 个圆两两比较加合法化,实测一秒以内;它超时说明宿主的工具出了问题。 */
|
||||
const VALIDATOR_SECONDS = 60;
|
||||
/** 合法化前先按容差 0 判原产物(方法.md 第八部分"合法化"第 1 条)。 */
|
||||
const TOLERANCE = "0";
|
||||
|
||||
// 常规辅助指标的编号(方法.md 第五部分"指标")。前三个来自 runner 的资源用量,后四个来自校验器。
|
||||
const WALL = "wall-seconds";
|
||||
const CPU = "cpu-seconds";
|
||||
const MEMORY = "peak-memory-bytes";
|
||||
const CLEARANCE = "min-pairwise-clearance";
|
||||
const RAW_SCORE = "raw-score";
|
||||
const RAW_VIOLATION = "raw-violation";
|
||||
const DEDUCTION = "legalization-deduction";
|
||||
const FROM_VALIDATOR = [CLEARANCE, RAW_SCORE, RAW_VIOLATION, DEDUCTION];
|
||||
|
||||
const SIDE = "单位正方形的边长";
|
||||
const PER_RUN = "每次运行结束后由宿主读一次,直接取值";
|
||||
|
||||
function auxiliary(
|
||||
id: string,
|
||||
meaning: string,
|
||||
unit: string,
|
||||
direction: "maximize" | "minimize",
|
||||
howMeasured: string,
|
||||
): MetricDefinition {
|
||||
return { id, layer: "auxiliary", meaning, unit, direction, howMeasured, missing: "recordMissing" };
|
||||
}
|
||||
|
||||
function metricDefinitions(facts: TaskFacts): MetricDefinition[] {
|
||||
const validator = `宿主的校验器从 ${SOLUTION} 重算,${PER_RUN}`;
|
||||
return [
|
||||
{
|
||||
id: facts.finalMetric.id,
|
||||
layer: "final",
|
||||
meaning:
|
||||
"合法摆法的半径之和,由宿主的独立校验器从实际产物重算;原产物差一点不合法时,先按 TASK.md 的只缩不移规则修,再按修后的半径算",
|
||||
unit: SIDE,
|
||||
direction: facts.finalMetric.direction,
|
||||
howMeasured: validator,
|
||||
missing: "invalidateRun",
|
||||
},
|
||||
auxiliary(WALL, "正式运行的墙钟时间", "秒", "minimize", `容器里的计量程序记录,${PER_RUN}`),
|
||||
auxiliary(CPU, "正式运行用掉的 CPU 秒(容器 cgroup 的 usage_usec)", "秒", "minimize", `容器里的计量程序记录,${PER_RUN}`),
|
||||
auxiliary(MEMORY, "正式运行的峰值内存(容器 cgroup 的 memory.peak)", "字节", "minimize", `容器里的计量程序记录,${PER_RUN}`),
|
||||
auxiliary(CLEARANCE, "所有圆对中「圆心距减两半径之和」的最小值,大于等于 0 表示不重叠;修过的取修后的摆法", SIDE, "maximize", validator),
|
||||
auxiliary(RAW_SCORE, "合法化之前原产物的半径之和", SIDE, "maximize", validator),
|
||||
auxiliary(RAW_VIOLATION, "原产物的最大违反量:两圆重叠的量和圆出边的量里最大的一个,0 表示没有违反", SIDE, "minimize", validator),
|
||||
auxiliary(DEDUCTION, "只缩不移合法化扣掉的分:原分数减最终成绩,原产物合法时是 0", SIDE, "minimize", validator),
|
||||
];
|
||||
}
|
||||
|
||||
/** 正式运行、校验器、轻量检查共用的运行定义。数学库的线程数和核数一致,否则会开多个线程抢核。 */
|
||||
function definition(task: Task, imageDigest: string, command: string[], wallSeconds: number, env: Record<string, string>): RunDefinition {
|
||||
const cores = String(task.facts.run.cores);
|
||||
return {
|
||||
command,
|
||||
workdir: WORKDIR,
|
||||
image: { digest: imageDigest, runtime: task.facts.run.runtime },
|
||||
wallSeconds,
|
||||
cores: task.facts.run.cores,
|
||||
inheritEnv: false,
|
||||
env: { OMP_NUM_THREADS: cores, OPENBLAS_NUM_THREADS: cores, MKL_NUM_THREADS: cores, ...env },
|
||||
};
|
||||
}
|
||||
|
||||
// 解释器不加 -I:-I 会忽略全部 PYTHON* 环境变量,连同 runner 设的 PYTHON_CPU_COUNT,
|
||||
// 于是 os.cpu_count() 报回宿主核数(本机实测 16)。容器里的环境变量全部由运行定义给出,用不着 -I。
|
||||
// -B:不写 __pycache__,不往运行目录里添文件。
|
||||
|
||||
function prepareRun(task: Task, candidateDir: string, runDir: string, hypothesisMetrics: HypothesisMetric[]): void {
|
||||
cpSync(candidateDir, runDir, { recursive: true });
|
||||
// harness 和指标说明以宿主的为准,候选目录里有同名文件也被盖掉(这两个名字在 task.json 的不许改名单里)。
|
||||
copyFileSync(join(task.dir, HARNESS), join(runDir, HARNESS));
|
||||
writeFileSync(join(runDir, METRICS_SPEC), `${JSON.stringify(hypothesisMetrics, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function formalRun(task: Task, imageDigest: string, seed: string): RunDefinition {
|
||||
const command = ["python", "-B", `${WORKDIR}/${HARNESS}`, "--program", ENTRY, "--output", SOLUTION];
|
||||
return definition(task, imageDigest, command, task.facts.run.wallSeconds, { INFORSI_SEED: seed });
|
||||
}
|
||||
|
||||
function lightCheck(task: Task, candidateDir: string, checkDir: string, imageDigest: string, seconds: number): RunDefinition {
|
||||
cpSync(candidateDir, join(checkDir, CHECK_CANDIDATE), { recursive: true });
|
||||
copyFileSync(join(task.dir, CHECK_SCRIPT), join(checkDir, CHECK_SCRIPT));
|
||||
const command = ["python", "-B", CHECK_SCRIPT, CHECK_CANDIDATE, ENTRY, CHECK_RESULT];
|
||||
return definition(task, imageDigest, command, seconds, {});
|
||||
}
|
||||
|
||||
/** 检查脚本写的结果文件。valid 与 reasons 是否为空必须一致。 */
|
||||
const CheckResult = Type.Object({ valid: Type.Boolean(), reasons: Type.Array(Type.String()) });
|
||||
|
||||
/** 宿主的工具(检查脚本、校验器)没正常跑完时报错停下:这不是候选的失败(任务书 05 第三部分)。 */
|
||||
function requireExit(what: string, result: ProcessResult, codes: number[]): number {
|
||||
if (result.ending === "exited" && codes.includes(result.exitCode)) return result.exitCode;
|
||||
const how = result.ending === "notStarted" ? `没启动:${result.reason}` : result.ending === "exited" ? `退出码 ${result.exitCode}` : result.ending;
|
||||
throw new Error(`${what}没有正常跑完(${how}),这是宿主一侧的工具出了问题,不能当成候选的失败`);
|
||||
}
|
||||
|
||||
function readJson(path: string): unknown {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (error) {
|
||||
throw new Error(`读不出 ${path}:${(error as Error).message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function readLightCheck(checkDir: string, result: ProcessResult): LightCheckOutcome {
|
||||
requireExit("轻量检查脚本", result, [0]);
|
||||
const path = join(checkDir, CHECK_RESULT);
|
||||
const check = readJson(path);
|
||||
if (!Value.Check(CheckResult, check) || check.valid !== (check.reasons.length === 0)) {
|
||||
throw new Error(`${path} 的内容认不出:${JSON.stringify(check)}`);
|
||||
}
|
||||
return check.valid ? { passed: true } : { passed: false, reasons: check.reasons };
|
||||
}
|
||||
|
||||
/** 正式运行没正常结束的原因;正常退出(退出码 0)时为 null。 */
|
||||
function processFailure(task: Task, formal: Exclude<ProcessResult, { ending: "notStarted" }>): string | null {
|
||||
if (formal.ending === "timeout") return `超过单次时限 ${task.facts.run.wallSeconds} 秒,被结束`;
|
||||
if (formal.ending === "cancelled") return "容器从外面被结束(关机、手动 docker kill 等),没有留下计量结果";
|
||||
if (formal.exitCode !== 0) return `正式运行以退出码 ${formal.exitCode} 结束`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function started(formal: ProcessResult): Exclude<ProcessResult, { ending: "notStarted" }> {
|
||||
if (formal.ending === "notStarted") throw new Error(`没启动的运行不判分:${formal.reason}`);
|
||||
return formal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼出 JudgedResult:资源用量来自正式运行,假设专用指标和两个保留键来自候选写的 metrics.json。
|
||||
* 有"缺了整次作废"的假设专用指标测不到时,原本算数的也改判不算数(方法.md 第五部分"指标")。
|
||||
*/
|
||||
function assemble(
|
||||
runDir: string,
|
||||
formal: Exclude<ProcessResult, { ending: "notStarted" }>,
|
||||
hypothesisMetrics: HypothesisMetric[],
|
||||
verdict: { valid: boolean; reason: string },
|
||||
score: Measured,
|
||||
fromValidator: Record<string, Measured>,
|
||||
extra: Json,
|
||||
): JudgedResult {
|
||||
const file = readMetricsFile(join(runDir, METRICS), METRICS, hypothesisMetrics);
|
||||
const voided = hypothesisMetrics.flatMap((m) => {
|
||||
const value = file.hypothesis[m.id]!;
|
||||
return m.missing === "invalidateRun" && value.status === "unmeasured" ? [`${m.id}(${value.reason})`] : [];
|
||||
});
|
||||
const result = verdict.valid && voided.length > 0
|
||||
? { valid: false, reason: `登记为"缺了整次作废"的假设专用指标测不到:${voided.join(";")}` }
|
||||
: verdict;
|
||||
return {
|
||||
verdict: result.valid ? "valid" : "invalid",
|
||||
verdictReason: result.reason,
|
||||
score,
|
||||
metrics: {
|
||||
[WALL]: formal.wallSeconds,
|
||||
[CPU]: formal.cpuSeconds,
|
||||
[MEMORY]: formal.peakMemoryBytes,
|
||||
...fromValidator,
|
||||
...file.hypothesis,
|
||||
},
|
||||
answerPath: file.answerPath,
|
||||
safeguardTriggers: file.safeguardTriggers,
|
||||
extra,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareJudging(
|
||||
task: Task,
|
||||
runDir: string,
|
||||
judgeDir: string,
|
||||
imageDigest: string,
|
||||
formal: ProcessResult,
|
||||
hypothesisMetrics: HypothesisMetric[],
|
||||
): ReturnType<TaskImplementation["prepareJudging"]> {
|
||||
const run = started(formal);
|
||||
const solution = join(runDir, SOLUTION);
|
||||
const failure = processFailure(task, run) ?? (existsSync(solution) ? null : `没有产物:运行目录里没有 ${SOLUTION}`);
|
||||
if (failure !== null) {
|
||||
const notValidated = Object.fromEntries(FROM_VALIDATOR.map((id) => [id, unmeasured(`没有跑校验器:${failure}`)]));
|
||||
const result = assemble(runDir, run, hypothesisMetrics, { valid: false, reason: failure }, unmeasured(failure), notValidated, {
|
||||
legalization: null,
|
||||
});
|
||||
return { status: "judged", result };
|
||||
}
|
||||
copyFileSync(solution, join(judgeDir, SOLUTION));
|
||||
copyFileSync(join(task.dir, VALIDATOR), join(judgeDir, VALIDATOR));
|
||||
const command = [
|
||||
"python",
|
||||
"-B",
|
||||
VALIDATOR,
|
||||
"--circles",
|
||||
String(CIRCLES),
|
||||
"--solution",
|
||||
SOLUTION,
|
||||
"--atol",
|
||||
TOLERANCE,
|
||||
"--legalize",
|
||||
"--verdict",
|
||||
VERDICT,
|
||||
];
|
||||
return { status: "validate", definition: definition(task, imageDigest, command, VALIDATOR_SECONDS, {}) };
|
||||
}
|
||||
|
||||
const NumberOrNull = Type.Union([Type.Number(), Type.Null()]);
|
||||
|
||||
/** validate.py --legalize 写的判定里,这里要读的部分;其余键(evidence、legal_radii 等)不读。 */
|
||||
const ValidatorVerdict = Type.Object({
|
||||
valid: Type.Boolean(),
|
||||
computed_sum: NumberOrNull,
|
||||
reasons: Type.Array(Type.String()),
|
||||
diagnostics: Type.Object({ min_pairwise_clearance: NumberOrNull }),
|
||||
legalization: Type.Object({
|
||||
raw_valid: Type.Boolean(),
|
||||
raw_score: NumberOrNull,
|
||||
raw_violation: NumberOrNull,
|
||||
applied: Type.Boolean(),
|
||||
repaired: Type.Boolean(),
|
||||
shrunk_circles: Type.Array(Type.Integer()),
|
||||
reasons: Type.Array(Type.String()),
|
||||
}),
|
||||
});
|
||||
type ValidatorVerdict = Static<typeof ValidatorVerdict>;
|
||||
|
||||
/** 校验器在形状不对、含非有限数或负半径时提前返回,几何量没算。 */
|
||||
const NOT_COMPUTED = "原产物形状不对、含非有限数或负半径,校验器没算这个量";
|
||||
|
||||
function orUnmeasured(value: number | null, reason: string): Measured {
|
||||
return value === null ? unmeasured(reason) : measured(value);
|
||||
}
|
||||
|
||||
function readVerdict(judgeDir: string, exitCode: number): ValidatorVerdict {
|
||||
const path = join(judgeDir, VERDICT);
|
||||
const verdict = readJson(path);
|
||||
if (!Value.Check(ValidatorVerdict, verdict)) throw new Error(`${path} 的内容认不出:${JSON.stringify(verdict)}`);
|
||||
// 退出码 0 = 合法、1 = 不合法;合法化之后的结论就是最终判定。对不上说明跑的不是这份校验器。
|
||||
const consistent =
|
||||
verdict.valid === (exitCode === 0) &&
|
||||
verdict.legalization.repaired === verdict.valid &&
|
||||
(!verdict.valid || verdict.computed_sum !== null);
|
||||
if (!consistent) throw new Error(`${path} 与退出码 ${exitCode} 或自身前后对不上:${JSON.stringify(verdict)}`);
|
||||
return verdict;
|
||||
}
|
||||
|
||||
function judge(
|
||||
_task: Task,
|
||||
runDir: string,
|
||||
judgeDir: string,
|
||||
formal: ProcessResult,
|
||||
validator: ProcessResult,
|
||||
hypothesisMetrics: HypothesisMetric[],
|
||||
): JudgedResult {
|
||||
const run = started(formal);
|
||||
const verdict = readVerdict(judgeDir, requireExit("校验器", validator, [0, 1]));
|
||||
const legal = verdict.legalization;
|
||||
let deduction: Measured;
|
||||
let score: Measured;
|
||||
if (verdict.valid) {
|
||||
const final = verdict.computed_sum!;
|
||||
score = measured(final);
|
||||
// legal.repaired 为真时 raw_score 一定有值:原产物能算出分数,才可能合法或被修好。
|
||||
const raw = legal.raw_score!;
|
||||
// 只缩半径只会降分;修后反而更高,说明修法写错了(方法.md 第八部分"合法化"第 3 条)。
|
||||
if (final > raw) throw new Error(`合法化后分数 ${final} 高于原分数 ${raw}:只缩半径只会降分,修法出错了`);
|
||||
deduction = measured(raw - final);
|
||||
} else {
|
||||
const reason = `不算数:${verdict.reasons.concat(legal.reasons).join(";")}`;
|
||||
score = unmeasured(reason);
|
||||
deduction = unmeasured(`修不了,没有修后分数:${legal.reasons.join(";")}`);
|
||||
}
|
||||
const rawScore = orUnmeasured(legal.raw_score, NOT_COMPUTED);
|
||||
const rawViolation = orUnmeasured(legal.raw_violation, NOT_COMPUTED);
|
||||
const fromValidator = {
|
||||
[CLEARANCE]: orUnmeasured(verdict.diagnostics.min_pairwise_clearance, NOT_COMPUTED),
|
||||
[RAW_SCORE]: rawScore,
|
||||
[RAW_VIOLATION]: rawViolation,
|
||||
[DEDUCTION]: deduction,
|
||||
};
|
||||
const reason = verdict.valid
|
||||
? legal.raw_valid
|
||||
? "校验器按容差 0 判原产物合法"
|
||||
: `原产物不合法,按只缩不移规则修好后合法(缩了 ${legal.shrunk_circles.length} 个圆)`
|
||||
: `校验器判不合法:${verdict.reasons.concat(legal.reasons).join(";")}`;
|
||||
const extra = {
|
||||
legalization: {
|
||||
rawValid: legal.raw_valid,
|
||||
applied: legal.applied,
|
||||
repaired: legal.repaired,
|
||||
shrunkCircles: legal.shrunk_circles,
|
||||
reasons: legal.reasons,
|
||||
rawScore,
|
||||
rawViolation,
|
||||
deduction,
|
||||
},
|
||||
};
|
||||
return assemble(runDir, run, hypothesisMetrics, { valid: verdict.valid, reason }, score, fromValidator, extra);
|
||||
}
|
||||
|
||||
export const circlePacking: TaskImplementation = {
|
||||
metricDefinitions,
|
||||
prepareRun,
|
||||
formalRun,
|
||||
lightCheck,
|
||||
readLightCheck,
|
||||
prepareJudging,
|
||||
judge,
|
||||
};
|
||||
Reference in New Issue
Block a user