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:
2026-10-02 13:55:47 -04:00
parent 6005fb8ec2
commit cf6bc4c844
25 changed files with 3205 additions and 6 deletions
+353
View File
@@ -0,0 +1,353 @@
"""填圆的独立校验器(宿主保管,候选不得修改)。
从实际产物重算一切。不导入官方 ShinkaEvolve 的 evaluate.py,也不读候选自报的分数。
成绩的定义:成绩就是下面那一句 ``math.fsum(radius_values)``。
只有这一处算它,所以不存在"两个成绩哪个算数"的问题。
**产物里的 ``reported_sum`` 这个字段照旧允许候选写**(``harness.py`` 就在写它),
校验器只是**根本不读它**:它不进判定、不进 diagnostics、不影响 ``valid``。
检查项(n = --circles,单位正方形 [0,1]^2):
- centers 形状 (n, 2),radii 形状 (n,)
- 全是有限数
- 半径不为负
- 每个圆在正方形内(容差 atol)
- 两两不重叠(容差 atol)
圆数、容差都没有默认值:忘了传就静默按旧值判,正是"改一处忘一处、没有任何东西会发现"
的那一类问题。所以 ``--circles``、``--atol`` 必填。
命令行:python -B validate.py --circles <n> --solution <solution.json> --atol <容差>
[--legalize] --verdict <verdict.json>
判定写进 ``--verdict`` 指定的文件(一个 JSON 对象),宿主读这个文件,不读标准输出。
退出码:0 = 合法,1 = 不合法,2 = 用法或读取出错(这时不写判定文件)。
校验器自己出错(例如算术异常)不兜住,带 traceback 非零退出、不写判定文件:
那是宿主的工具坏了,不能当成候选的失败。
"""
import argparse
import json
import math
import sys
# 半径分布那一行最多列几段;超了就中间省略,省略号让"截断了"这件事看得见。
PROFILE_MAX_GROUPS = 8
def _is_finite_number(value) -> bool:
if not isinstance(value, (int, float)) or isinstance(value, bool):
return False
try:
return math.isfinite(value)
except OverflowError:
# JSON integers can exceed the range of the float geometry protocol.
return False
def render_radius_profile(radius_values) -> str:
"""把半径分布写成一行文本(判定里的 ``evidence.radius_profile``)。
降序排列,**数值完全相同**的连成一段写作 ``值 x个数``;只是四舍五入到小数点后
四位之后看着一样、实际不同的两个数仍然各占一段(所以会出现
``0.1580, 0.1580`` 这种相邻重复,它说明这是两个不同的数)。
例:``3 radii, desc: 0.2500 x2, 0.0000``
``26 radii, desc: 0.5000, 0.0000 x25``
"""
groups: list[list] = []
for value in sorted(radius_values, reverse=True):
if groups and groups[-1][0] == value:
groups[-1][1] += 1
else:
groups.append([value, 1])
rendered = [
f"{value:.4f}" if count == 1 else f"{value:.4f} x{count}"
for value, count in groups
]
if len(rendered) > PROFILE_MAX_GROUPS:
rendered = rendered[: PROFILE_MAX_GROUPS - 2] + ["...", rendered[-1]]
return f"{len(radius_values)} radii, desc: " + ", ".join(rendered)
def validate_packing(centers, radii, circles, atol) -> dict:
"""Validate a packing and recompute its score independently.
``circles`` 是任务声明的圆数(调用方必须给,没有默认值)。
Returns a structured verdict dict. All applicable reasons are collected
(not just the first failure) so diagnostics stay informative.
"""
reasons: list[str] = []
diagnostics: dict = {
"declared_circles": circles,
"atol": atol,
"num_circles": None,
"max_boundary_violation": None,
"max_overlap_violation": None,
"min_pairwise_clearance": None,
"min_radius": None,
"max_radius": None,
"zero_radius_count": None,
"all_radii_finite": None,
}
evidence: dict = {"radius_profile": None}
computed_sum = None
def verdict() -> dict:
return {
"valid": len(reasons) == 0,
"computed_sum": computed_sum,
"reasons": reasons,
"diagnostics": diagnostics,
"evidence": evidence,
}
# 候选实际返回了几个圆:只要 centers 是个列表,这个数就是事实,先记下来。
# 数量不对时它正好说明错在哪里,记成 null 就看不出来了。
if isinstance(centers, list):
diagnostics["num_circles"] = len(centers)
# Shape and element checks.
shape_ok = (
isinstance(centers, list)
and len(centers) == circles
and all(isinstance(pair, list) and len(pair) == 2 for pair in centers)
and isinstance(radii, list)
and len(radii) == circles
)
if not shape_ok:
reasons.append(
f"shape incorrect: expected centers ({circles}, 2) and radii "
f"({circles},), got centers "
f"{(len(centers), [len(p) if isinstance(p, list) else type(p).__name__ for p in centers[:3]]) if isinstance(centers, list) else type(centers).__name__} "
f"radii {len(radii) if isinstance(radii, list) else type(radii).__name__}"
)
return verdict()
# 半径那几个诊断量只跟半径有关,圆心坏了不影响它们,所以先算。
radii_finite = all(_is_finite_number(r) for r in radii)
diagnostics["all_radii_finite"] = radii_finite
radius_values: list[float] | None = None
if radii_finite:
radius_values = [float(r) for r in radii]
diagnostics["min_radius"] = min(radius_values)
diagnostics["max_radius"] = max(radius_values)
diagnostics["zero_radius_count"] = sum(1 for r in radius_values if r == 0.0)
evidence["radius_profile"] = render_radius_profile(radius_values)
centers_finite = all(_is_finite_number(v) for pair in centers for v in pair)
if not (centers_finite and radii_finite):
reasons.append("non-finite values found in centers or radii")
# Cannot trust geometry with non-finite or non-numeric values; stop here.
return verdict()
# 走到这里 radii_finite 必然为真,radius_values 已经在上面算好。
points = [(float(pair[0]), float(pair[1])) for pair in centers]
negative = [i for i, r in enumerate(radius_values) if r < 0]
if negative:
reasons.append(f"negative radii at indices: {negative}")
# Negative radii make geometry checks meaningless; stop here.
return verdict()
# Independent recomputation of the score.
computed_sum = math.fsum(radius_values)
# Boundary check: circle i must lie inside [0,1]^2 within atol.
max_boundary = 0.0
boundary_indices = []
for i, ((x, y), r) in enumerate(zip(points, radius_values, strict=True)):
violation = max(r - x, x + r - 1.0, r - y, y + r - 1.0)
max_boundary = max(max_boundary, violation)
if violation > atol:
boundary_indices.append(i)
diagnostics["max_boundary_violation"] = max_boundary
if boundary_indices:
reasons.append(
f"circles outside unit square at indices: {boundary_indices} "
f"(max violation {max_boundary!r}, atol={atol})"
)
# Pairwise overlap check.
max_overlap = 0.0
min_clearance = math.inf
overlap_pairs = []
for i in range(circles):
xi, yi = points[i]
for j in range(i + 1, circles):
xj, yj = points[j]
dist = math.hypot(xi - xj, yi - yj)
clearance = dist - (radius_values[i] + radius_values[j])
min_clearance = min(min_clearance, clearance)
violation = -clearance
max_overlap = max(max_overlap, violation)
if violation > atol:
overlap_pairs.append((i, j))
diagnostics["max_overlap_violation"] = max_overlap
diagnostics["min_pairwise_clearance"] = (
min_clearance if math.isfinite(min_clearance) else None
)
if overlap_pairs:
reasons.append(
f"overlapping circle pairs: {overlap_pairs[:5]}"
f"{' ...' if len(overlap_pairs) > 5 else ''} "
f"(max violation {max_overlap!r}, atol={atol})"
)
return verdict()
def legalize_packing(centers, radii, circles, atol) -> dict:
"""只缩不移合法化(docs/方法.md 第八部分"合法化",规则原文写在同目录 TASK.md)。
1. 先按 ``atol`` 判原产物。合法就原样返回,扣分 0。
2. 不合法时只缩半径、不挪圆心:**只缩参与违反的圆**——每个圆的半径减去它参与的
最大违反量(跟别的圆重叠的量、出边的量取最大)。
3. 缩完仍不合法(浮点舍入留下的零头),把这些圆的半径各往 0 挪一个最小浮点单位,
再判,直到合法。
4. 修不了:形状不对、含非有限值或负半径(这些不是"差一点",缩半径解决不了);
有圆心在正方形外面;这些圆的半径都缩到 0 仍不合法。
返回值顶层是**最后被判的那份摆法**的裁决(修好了就是修后的,修不了就是原来的),
外加 ``legalization`` 一节:原产物合不合法、原分数、原违反量、缩了哪些圆、修后半径。
扣掉多少分由宿主拿原分数减修后分数算,这里不另报一个数。
"""
raw = validate_packing(centers, radii, circles, atol=atol)
diagnostics = raw["diagnostics"]
violations = [
v
for v in (
diagnostics.get("max_overlap_violation"),
diagnostics.get("max_boundary_violation"),
)
if v is not None
]
record: dict = {
"raw_valid": raw["valid"],
"raw_score": raw["computed_sum"],
# 形状、有限性、负半径那几步提前返回时几何检查没做,原违反量量不到,记 null。
"raw_violation": max(violations) if len(violations) == 2 else None,
"applied": False,
"repaired": raw["valid"],
"shrunk_circles": [],
"legal_radii": None,
"reasons": [],
}
def finish(verdict: dict) -> dict:
return {**verdict, "legalization": record}
if raw["valid"]:
return finish(raw)
if raw["computed_sum"] is None:
record["reasons"].append(
"形状不对、含非有限值或有负半径:这不是差一点的违规,缩半径修不了"
)
return finish(raw)
points = [(float(x), float(y)) for x, y in centers]
outside = [i for i, (x, y) in enumerate(points) if not (0.0 <= x <= 1.0 and 0.0 <= y <= 1.0)]
if outside:
record["reasons"].append(
f"圆心在正方形外面(编号 {outside}):只缩半径不挪圆心,修不了"
)
return finish(raw)
radius_values = [float(r) for r in radii]
# 每个圆参与的最大违反量。只有超过 atol 的才算违反,减的是违反量本身。
shrink = [0.0] * circles
for i, ((x, y), r) in enumerate(zip(points, radius_values, strict=True)):
boundary = max(r - x, x + r - 1.0, r - y, y + r - 1.0)
if boundary > atol:
shrink[i] = boundary
for i in range(circles):
xi, yi = points[i]
for j in range(i + 1, circles):
xj, yj = points[j]
overlap = radius_values[i] + radius_values[j] - math.hypot(xi - xj, yi - yj)
if overlap > atol:
shrink[i] = max(shrink[i], overlap)
shrink[j] = max(shrink[j], overlap)
involved = [i for i in range(circles) if shrink[i] > 0.0]
record["applied"] = True
record["shrunk_circles"] = involved
legal = [max(r - s, 0.0) for r, s in zip(radius_values, shrink, strict=True)]
while True:
verdict = validate_packing(centers, legal, circles, atol=atol)
if verdict["valid"]:
record["repaired"] = True
record["legal_radii"] = legal
return finish(verdict)
nudged = [
math.nextafter(legal[i], 0.0) if shrink[i] > 0.0 else legal[i]
for i in range(circles)
]
if nudged == legal:
record["reasons"].append(
f"参与违反的圆(编号 {involved})半径都缩到 0 仍不合法,修不了"
)
return finish(raw)
legal = nudged
def main() -> int:
parser = argparse.ArgumentParser(
description="Independent circle-packing validator (unit square)."
)
parser.add_argument("--solution", required=True, help="solution JSON path")
parser.add_argument(
"--circles",
type=int,
required=True,
help="how many circles the task declares (positive integer)",
)
parser.add_argument(
"--atol",
type=float,
required=True,
help="absolute tolerance for boundary/overlap checks",
)
parser.add_argument(
"--legalize",
action="store_true",
help=(
"原产物不合法时按只缩不移规则修(见 legalize_packing),"
"输出多一节 legalization;不加时只判不修"
),
)
parser.add_argument(
"--verdict", required=True, help="判定写进这个文件(JSON 对象)"
)
args = parser.parse_args()
if args.circles < 1:
print("--circles must be a positive integer", file=sys.stderr)
return 2
if not (math.isfinite(args.atol) and args.atol >= 0):
print("--atol must be finite and non-negative", file=sys.stderr)
return 2
try:
with open(args.solution, encoding="utf-8") as handle:
solution = json.load(handle)
except (OSError, ValueError, RecursionError) as error:
print(f"cannot read solution: {error}", file=sys.stderr)
return 2
if not isinstance(solution, dict):
print("solution must be a JSON object", file=sys.stderr)
return 2
judge = legalize_packing if args.legalize else validate_packing
verdict = judge(
solution.get("centers"),
solution.get("radii"),
args.circles,
atol=args.atol,
)
with open(args.verdict, "w", encoding="utf-8") as handle:
json.dump(verdict, handle, allow_nan=False)
return 0 if verdict["valid"] else 1
if __name__ == "__main__":
sys.exit(main())