Files
InfoRSI/tasks/circle_packing/harness.py
T
iomgaa cf6bc4c844 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>
2026-10-02 13:55:47 -04:00

199 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""填圆任务的运行 harness(宿主的固定工具,候选不得修改)。
它按文件路径导入候选程序、调用候选定义的 ``run_packing()``、把原始解原子落盘。
**它不判定解是否合法**:成绩只由独立校验器 ``validate.py`` 事后从实际产物重算。
指标的分工(docs/方法.md 第五部分"指标"):
- 最终指标和合法性判定归 ``validate.py``,跟候选无关;
- 假设专用指标由研究者在协议里登记、实验员按定义在候选代码里实现并写出
``metrics.json``,宿主直接采信。harness 不内置任何指标算法。
命令行:python -B harness.py --program <program.py> --output <solution.json>
种子从环境变量 INFORSI_SEED 读,必须给出。
成功退出 0;任何失败都带 traceback 写 stderr 并非零退出。
工作目录里的三个文件(给实验员看的完整契约在同目录 ``TASK.md`` 的"指标怎么交给宿主"):
- ``metrics-spec.json``:本次运行登记的假设专用指标定义,由宿主在准备运行目录时写好,
harness 不碰它。
- ``solution.json``(``--output`` 指定):候选返回的原始解。
- ``metrics.json``:实验员的代码写出的假设专用指标取值,**键必须是 metrics-spec.json 里
那条指标的 ``id`` 原文**,值是有限数或 null,路径相对工作目录,
且必须在 ``run_packing()`` 返回之前写完。没有这个文件就表示这一次的假设专用指标一个
都没测到。另有两个保留键(docs/方法.md 第五部分"答案路径"):``answer-path``(字符串,
最终交的答案出自哪条路径)和 ``safeguard-triggers``(对象,每条保障动手几次)。
它们不是指标、不是数,harness 原样保留,宿主读成辅助记录。
"""
import argparse
import hashlib
import importlib.util
import json
import math
import os
import random
import sys
from typing import Any, cast
METRICS_FILE = "metrics.json"
# metrics.json 里不是数的两个保留键(docs/方法.md 第五部分"答案路径")。finalize_metrics 只改写指标值,这两个原样留着:
# 改写成 null 的话,候选写对了的答案路径到宿主那里就成了"不认识的路径:null",出错后无法察觉。
RESERVED_KEYS = ("answer-path", "safeguard-triggers")
def load_program(program_path: str):
"""按显式文件路径导入候选模块(不假定固定的 main.py 模式)。"""
spec = importlib.util.spec_from_file_location("candidate_program", program_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"无法从 {program_path} 加载候选模块")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def write_json_atomically(path: str, payload) -> None:
"""先写临时文件再 os.replace,让崩溃的运行永远不会留下半截产物。"""
temporary = path + ".tmp"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(payload, handle, allow_nan=False)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
directory_fd = os.open(
os.path.dirname(os.path.abspath(path)), os.O_RDONLY | os.O_DIRECTORY
)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
def is_finite_number(value) -> bool:
"""有限数的定义:不是布尔值的 int/float,且 math.isfinite。
布尔值排除在外,否则 ``true`` 会被当成 1 记进证据。
超出浮点范围的大整数 math.isfinite 会抛 OverflowError,一并算作非有限。
"""
if not isinstance(value, (int, float)) or isinstance(value, bool):
return False
try:
return math.isfinite(value)
except OverflowError:
return False
def finalize_metrics() -> None:
"""把实验员写出的 metrics.json 整理成宿主读得进去的形状,然后原子落盘。
只做一件事:指标的非有限值(NaN、±inf、布尔、非数值)**原样改写成 null**,其余照抄;
两个保留键(RESERVED_KEYS)不是指标,原样保留。
这正是"测不到就说测不到"。被改写的编号在 stderr 记一行,**退出码保持 0**。
为什么不抛错——这里抛错会让情况变坏,不是变好:
1. 它防不住自述要防的那件事。实验员把测不到的量写成 0.0 照样放行。
2. 它实际拦的几种情况宿主已经逐条拦过,而且处理温和得多:那一条记 null
加一句原因,成绩照常。
3. 抛错时 solution.json 已经完整落盘、解合法、成绩算得出来,但非零退出码
会让宿主判定这次运行没干净结束,最终指标也记成 null——一条坏指标白白
作废掉整次运行,而运行名额照扣。更糟的是那份裸 NaN 的 metrics.json 会
留在盘上,JS 的 JSON.parse 不接受裸 NaN,于是**这一轮所有候选指标一起
丢**,不只是坏的那一条。
它防的是"模型正常工作时就会做"的那类失手:实验员算某条比值时碰上退化布局
(分母是零半径)拿到 nan 没接住。改写成 null 之后,坏的那条变成 null 加原因,
好的那些照常,成绩和运行名额都不受影响。
**不**校验键必须是 metrics-spec.json 里声明过的那些:宿主只按登记的编号取值,
多出来的键不读。
文件不存在表示候选指标一个都没测到,不补零也不补空文件。
"""
if not os.path.exists(METRICS_FILE):
return
with open(METRICS_FILE, encoding="utf-8") as handle:
try:
measured = json.load(handle)
except ValueError as error:
# 同样不抛错,理由同上:整份读不出来是宿主那边看得见的一个状态
# (它会记一句"metrics.json 读不出来"),不该连累已经算出来的成绩。
print(f"{METRICS_FILE} 不是合法 JSON,原样留在盘上: {error}", file=sys.stderr)
return
if not isinstance(measured, dict):
print(
f"{METRICS_FILE} 必须是 JSON 对象(指标编号 -> 值),实际是 "
f"{type(measured).__name__},原样留在盘上",
file=sys.stderr,
)
return
rewritten: dict = {}
replaced: list[str] = []
for metric_id, value in measured.items():
if metric_id in RESERVED_KEYS or value is None or is_finite_number(value):
rewritten[metric_id] = value
else:
rewritten[metric_id] = None
replaced.append(f"{metric_id}={value!r}")
if replaced:
print(
f"{METRICS_FILE} 里这些指标的值不是有限数,已按'测不到就写 null'改写成 "
f"null: {', '.join(replaced)}",
file=sys.stderr,
)
write_json_atomically(METRICS_FILE, rewritten)
def main() -> int:
parser = argparse.ArgumentParser(
description="运行填圆候选程序并落盘它返回的原始解。"
)
parser.add_argument(
"--program",
required=True,
help="定义了 run_packing() 的候选程序文件路径",
)
parser.add_argument(
"--output",
required=True,
help="原始解 JSON 的写出路径(centers、radii、reported_sum)",
)
args = parser.parse_args()
# 运行目录是候选目录整个复制来的,里面可能留着以前某次运行写的这两个输出文件。
# 候选这次没写时,宿主会把旧值当成这次的产物读走,所以先删掉,让读到的只可能是这次写的。
for leftover in (args.output, METRICS_FILE):
if os.path.exists(leftover):
os.remove(leftover)
# 在导入候选模块之前设种子。宿主给的是一个种子字符串(同一份研究的全部运行共用,
# docs/方法.md 第十部分要求评测确定),取它的 sha256 前 4 字节大端整数。
# 没给就报 KeyError 停下,不替它补一个。
np = importlib.import_module("numpy")
seed = int.from_bytes(
hashlib.sha256(os.environ["INFORSI_SEED"].encode()).digest()[:4],
"big",
)
random.seed(seed)
np.random.seed(seed)
module = load_program(args.program)
run_packing = getattr(module, "run_packing", None)
if not callable(run_packing):
raise RuntimeError(f"{args.program} 没有定义可调用的 run_packing()")
# 候选模块是动态加载的,下面的转换就是协议本身的强制点。
centers, radii, reported_sum = cast(Any, run_packing())
solution = {
"centers": [[float(x), float(y)] for x, y in centers],
"radii": [float(r) for r in radii],
"reported_sum": float(reported_sum),
}
write_json_atomically(args.output, solution)
finalize_metrics()
return 0
if __name__ == "__main__":
sys.exit(main())