Files
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

354 lines
14 KiB
Python
Raw Permalink 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.
"""填圆的独立校验器(宿主保管,候选不得修改)。
从实际产物重算一切。不导入官方 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())