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