"""填圆的独立校验器(宿主保管,候选不得修改)。 从实际产物重算一切。不导入官方 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 --solution --atol <容差> [--legalize] --verdict 判定写进 ``--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())