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
+10
View File
@@ -0,0 +1,10 @@
# 基础镜像按 digest 钉死,不用 tag——tag 会被重新指向,digest 不会。
# 这里写全仓库名(dockerproxy.net/library/python),因为本机没配 registry mirror,
# 只写 python 会去 docker.io 拉。
FROM dockerproxy.net/library/python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0
# 每次运行只给一个核。numba 不设这个变量时 parallel=True 会按宿主核数开线程
# (调研_填圆运行环境依赖.md 3.3 实测开 16 个);写在镜像里,由镜像编号钉住,
# 不依赖每份配置记得写。数学库的线程数由 task.env 的 OMP_NUM_THREADS 管。
ENV NUMBA_NUM_THREADS=1
COPY requirements.txt /req.txt
RUN pip install --no-cache-dir -r /req.txt
+295
View File
@@ -0,0 +1,295 @@
# 填圆任务(n=26,单位正方形)
这份文件是给实验员看的任务契约:要做什么、产物写在哪、键名怎么写。
读它的时候假定你手上已经有一份冻结的提案,本文件不重复提案里的内容。
## 目标
在单位正方形 `[0,1]²` 内放置 **26 个互不重叠、不越过边界**的圆,**最大化半径之和**。
最终指标:`sum-of-radii`,越大越好。
量级参照:容差 0 的口径下,26 个圆公开的最好半径和约 **2.635983**(AlphaEvolve 续作公开的构造,
按它自己的严格校验在本机重算得 2.6359830849)。常见的"2.635"是白皮书截到三位小数的数,不是另一个纪录。
别处报告的更高的数要先看容差:容差放到 1e-6,把每个半径加 5e-7 也判合格,和就能到 2.635996——那是口径不同,不是更好的摆法。
**成绩是从你交出来的产物算的,不是从你返回的那个数读的。**
`validate.py` 读 `solution.json` 里的 26 个半径,用 `math.fsum` 求和,那就是成绩。
你在 `run_packing()` 里返回的第三个值(`sum_of_radii`)**不是成绩**。
`harness.py` 照常把它写进 `solution.json` 的 `reported_sum` 字段,但
`validate.py` 根本不读这个字段:它不进判定、不进诊断,也不跟任何数对账。
所以这个数写过期了、跟半径对不上,产物照样合法,成绩照样是算出来的那一个。
只有一件事要注意,而且跟对账无关:`harness.py` 落盘时不接受非有限数,
所以这个值要是 `NaN` 或无穷大,整份 `solution.json` 根本写不出来,
这次运行连产物都没有。返回一个普通的有限数就行,哪怕它是错的。
## 评测会收下什么
判断代码里一段检查、修复或兜底"挡掉了谁、改了什么",要先知道评测本身会收下什么。
**评测收下一次运行,按下面的次序:**
1. 你交的 `solution.json` 形状对(26 个圆心、26 个半径)、全是有限数、半径不为负、圆心都在正方形里。
这几条任何一条不成立,评测**不收**,也不修。
2. 原产物按**容差 0** 判:任意两圆不重叠、每个圆不越出正方形边。成立就收下,成绩是原产物的半径和。
3. 不成立时,先按下一节"只缩不移合法化"修一次,修后再按容差 0 判。修得好就收下,成绩是修后的半径和
(只比原产物低一点);修不好才不收。
所以"差一点不合法"(重叠或越界只有浮点零头那么大)的产物,**评测本来会收**,只扣掉零头。
候选代码里比这更严的检查——例如容差 0 判不合法就把这个起点整个丢掉、或换成另一份解——挡掉的正是评测会收的产物,
它改的是交出去的答案,不是"让代码合法"。反过来,只在评测确实不收的情形(形状不对、非有限数、圆心出界)
才动手修或换的代码,在评测会收的运行上不改答案。
## 差一点不合法时:只缩不移合法化
校验器先按容差 0 判你交的原产物。原产物不合法时,宿主按下面这条写死的规则修一次,
**只缩半径、不挪圆心**,修好了就用修后的半径和当成绩:
1. **只缩参与违反的圆。**每个圆的半径减去它参与的最大违反量——跟别的圆重叠的量、
越出正方形边的量,取最大的那个。没参与违反的圆一个都不动。
2. 缩完仍不合法(浮点舍入留下的零头),把这些圆的半径各往 0 挪一个最小的浮点单位,
再判,直到合法。
3. 同一个校验器按容差 0 重判修后的产物。
4. **修不了**:有圆心在正方形外面;参与违反的圆半径都缩到 0 仍不合法;
形状不对、含非有限值或负半径(这些不是"差一点",缩半径解决不了)。
修不了的运行照旧记失败,运行次数照扣——跟跑崩、超时、没产物一样。
**缩小只会降分**,所以这条规则不会让不合法的摆法比合法的更高分。它管的是"浮点上差一点":
例如两个圆重叠 2.8e-17,修后扣掉约 2.2e-16。要一分不扣,就在代码里给每个圆留一点空隙。
每次运行多记三个数,跟成绩一起进运行记录:原违反量(`raw-violation`)、
原分数(`raw-score`)、合法化扣掉的分(`legalization-deduction`,原产物合法时是 0)。
## 什么样的结果才算数
下面五条不是禁令,是**这次实验要成立需要的条件**。写出来是为了让你知道该往哪边走,
不是为了防谁。
### 一 · 成绩必须是算出来的,不能是写死的
代码里可以有常数:半径比例、松弛步长、网格怎么划分、迭代多少轮,都可以写死。
**不能有的是一张 26 行的答案表**——把某次跑出来的圆心和半径直接抄进源码返回。
*为什么*:研究要找的是方法,不是一个数。一份写死的答案没有机制可以解释——
问它"为什么这个改法有效",答案是"因为答案是抄的",而这句话不会出现在提案里。
一个没有方法的高分会让整轮研究的结论是假的:后面几轮会建立在
"某某方向有效"这个不存在的发现之上。
### 二 · 只读工作目录里的东西,不联网
你可以带自己的数据文件,它们会跟代码一起冻结、一起放进工作目录。
但运行时不要去读工作目录外面的文件。正式运行的容器是断网的,网络请求一律失败。
*为什么*:冻结的代码要能完整说清"这次跑的是什么"。
从网上取一份东西进来,那份东西明天可能就变了,这次运行就再也重现不了;
读工作目录外面的文件也一样,那个文件不在冻结的代码里。
### 三 · 在执行限额之内跑完
每次正式运行的墙钟时限是 120 秒、1 核,同一批候选都一样。到时限还没结束,进程被直接杀掉,
这次运行记为超时失败,运行次数照扣。
*为什么*:跟基线比的前提是同样的条件。用一百倍时间换来的成绩,
说明的是算力,不是方法。
### 四 · 用了多少算力要说出来
每次运行都记墙钟时间(`wall-seconds`)、CPU 秒(`cpu-seconds`)和峰值内存(`peak-memory-bytes`),
材料里同时写"是基线的几倍"和"占单次时限的几成"。这些只记录,不影响采纳。
**加大搜索预算本身是正当的做法**,不需要藏着。但用远多于基线的算力换来的提升,
说明的是算力,不是方法——所以要在提案里写明加了多少、为什么值得。
*为什么*:跟基线比的前提是同样的条件。一次多花一百五十倍时间、换来百分之七提升的运行,
如果被记成"某某改法有效",后面几轮会去改那个改法,而真正起作用的是预算。
### 五 · 说了什么就要真的做什么
代码和注释里写了"迭代到收敛",就要真的迭代到收敛;写了"最多 1000 步",
就不能实际只跑 2 步。
*为什么*:这一条跟前面几条不一样,**它坏的不是成绩,是解释**。
成绩可能完全是真的、可复现的,但"松弛到收敛有效"这条结论会进记录,
下一轮建立在一个没有发生过的机制之上。对不上的时候,
该作废的是那条结论,不是这次运行。
这也是为什么第一条说"没有方法的高分会让整轮研究的结论是假的"——
证据链上的错,后面每一轮都看不见。
## 运行方法
候选可以是多文件 Python 项目,普通相对数据资源会保留;入口 `initial.py` 必须定义:
```python
def run_packing():
...
return centers, radii, sum_of_radii # (26,2)、(26,)、float
```
正式运行由程序启动,在任务镜像里、断网、1 核、120 秒时限下执行(工作目录就是你的代码目录):
```bash
python -B harness.py --program initial.py --output solution.json
```
跑完以后,宿主把 `solution.json` 复制到另一个目录,用自己保管的校验器判分:
```bash
python -B validate.py --circles 26 --solution solution.json --atol 0 --legalize --verdict verdict.json
```
`harness.py` 在 import 候选前,用环境变量 `INFORSI_SEED` 给的种子字符串(取 SHA256 前 4 字节大端整数)
初始化 Python 的 `random` 和 numpy 的 `random`,执行候选并原子落盘原始解,不做任何有效性判断。
import 之前它还会删掉工作目录里已有的 `solution.json` 和 `metrics.json`,宿主读到的只会是这一次写的。
你要用随机,用这两个。同一份研究的全部运行用同一个种子,所以同一份代码跑两次,结果逐位相同。
`validate.py` 是独立校验器,重算半径和并检查形状 / 有限 / 非负 / 边界 / 重叠;
加 `--legalize` 时按上面"只缩不移合法化"一节修差一点不合法的产物(正式评测就是这样调的)。
写代码的过程中可以做**轻量检查**:由宿主在同一个镜像里跑,只看语法和入口函数,
不执行候选的任何一行代码。它有次数和时长上限(每次几秒),不算一次正式运行。
**不要自己跑正式规模的求解。** 正式运行要先登记、再启动、再扣次数,由程序统一做;
正式规模的求解哪怕叫"调试",也是正式运行,不能借轻量检查或自己的 shell 绕开预算。
## 指标怎么交给宿主(这一节最容易写错,先读完再动手)
工作目录里有两个指标文件,一进一出。**路径都相对工作目录**,也就是 `harness.py`
运行时的 cwd,不要写绝对路径。
### `metrics-spec.json`:宿主写给你的
宿主在准备运行目录时写好它,内容是这次运行登记的假设专用指标定义,
一个 JSON 数组,每个元素是一条指标:
| 字段 | 含义 |
| --- | --- |
| `id` | 指标编号,宿主按它取值 |
| `meaning` | 这个量测的是什么 |
| `unit` | 单位 |
| `direction` | `maximize` 越大越好,`minimize` 越小越好 |
| `howMeasured` | 研究者写的测法:在什么时刻测、测几次、怎么合成一个数。**你照着它实现** |
| `missing` | 测不到时这次运行怎么算:`recordMissing` 只记缺失、成绩照常;`invalidateRun` 整次作废 |
没有假设专用指标时,这个文件是一个空数组 `[]`。
### `metrics.json`:你写给宿主的
一个 JSON 对象:**键必须是 `metrics-spec.json` 里那条指标的 `id` 字段原文**,
值是有限数或 `null`。另外**每次都要写两个保留键**(下面"两个保留键"一节),它们不是指标、值不是数。
> 编号从 `metrics-spec.json` 里**照抄**,不要自己拼。宿主按编号取值,键对不上,
> 这条指标就记成"测不到"加一句原因——**而成绩完全正常**,报告上看不出任何异常,
> 只是研究者分析机制时悄悄少了一块证据。
四条硬要求:
1. **键照抄 `id`**,见上。
2. **值是有限数或 `null`。测不到就写 `null`,不要写 0。**
写 0 之后宿主分不出"测到了一个 0"和"没测到",而这种错事后从证据里查不出来。
算出 `NaN` / `inf`(退化布局里零半径参与的比值最常见)也一样,那是"没测到"。
harness 会把非有限值原样改写成 `null` 并在 stderr 记一行,退出码仍是 0——
成绩不受影响,但那条指标这一次就是没有数据。
3. **必须在 `run_packing()` 返回之前写完。** harness 在候选返回后立刻整理这个文件;
之后再写进去的内容不会被整理(非有限值不会变成 `null`,裸 `NaN` 会让宿主整份读不出来,
**这一次所有假设专用指标一起丢**,不只是坏的那一条)。
4. 键多了无所谓,宿主只按 `metrics-spec.json` 里的编号取值;键少了,缺的那条记"测不到"加一句原因。
文件不存在表示这一次的假设专用指标一个都没测到——这是一个明确状态,不是错误,
但它和"写错了键名"在报告里看起来一样,所以别靠它。
**测不到的后果看 `missing`**:选了 `invalidateRun` 的指标测不到,这次运行整次作废、不能被采纳,
运行次数照扣;选了 `recordMissing` 的只记缺失。
### 两个保留键:答案出自哪条路径
研究者不能运行代码,"最终交的答案到底是哪段代码给的"只能读代码猜,猜错了没人发现。
所以每次运行由你的代码自己说出来,写在同一个 `metrics.json` 里:
| 键 | 值 | 写什么 |
| --- | --- | --- |
| `answer-path` | 字符串 | 最终交的答案出自哪里:`main`(正常主流程),或冻结协议因子清单里某个因子的编号(`p1`、`p2`……),或保障清单里某条保障的短名 |
| `safeguard-triggers` | 对象 | 键是冻结协议保障清单里的**每一条**保障短名,值是这次运行里它动手了几次(非负整数,没动手写 0);保障写"无"时写 `{}` |
- 这两个键**不是指标**,不参与成绩判定;写错、没写都不作废运行,宿主照实记"候选没写答案路径"
或"不认识的路径:原文"。但研究者下一阶段就靠它看"这一格量到的是因子还是保障",所以要如实写。
- 消融格(关掉某个因子的版本)同样要写:关掉的因子不会是答案路径;如果关掉之后答案改由某条保障给出,就写那条保障。
- harness 不改写这两个键(指标值才会把非有限数改写成 `null`)。同样要在 `run_packing()` 返回之前写完。
### 一小段示例
```python
import json
import math
SPEC_FILE = "metrics-spec.json"
METRICS_FILE = "metrics.json"
def run_packing():
with open(SPEC_FILE, encoding="utf-8") as handle:
spec = json.load(handle)
# spec[i]["id"] 就是宿主认的编号,spec[i]["howMeasured"] 是照着实现的测法。
# 这里按这次实际条数对号入座,下面只演示一条。
spread_id = spec[0]["id"]
centers, radii, reported_sum = solve()
spread = float(max(radii) - min(radii))
measured = {
spread_id: spread if math.isfinite(spread) else None,
# 两个保留键:答案出自主流程;协议里的一条保障 "shrink-repair" 这次没动手。
"answer-path": "main",
"safeguard-triggers": {"shrink-repair": 0},
}
# 在 return 之前写完,路径相对工作目录。
with open(METRICS_FILE, "w", encoding="utf-8") as handle:
json.dump(measured, handle)
return centers, radii, reported_sum
```
最终指标 `sum-of-radii` 和合法性判定**不经过这条链**:它们由 `validate.py`
从 `solution.json` 独立重算,你不用也不该在 `metrics.json` 里写它们。
## 允许修改范围
候选只能修改自己代码目录里的 `*.py` 和自带的数据文件(基线的入口是 `initial.py`,其中
`EVOLVE-BLOCK-START` / `EVOLVE-BLOCK-END` 之间是官方预留的进化区域)。
不许改的文件见同目录 `task.json` 的 `protectedFiles`;程序会检查有没有改它们。
`harness.py` 和 `metrics-spec.json` 在准备运行目录时由宿主放进去,你目录里的同名文件会被盖掉;
校验器和轻量检查脚本由宿主保管,不在你的目录里。也不要改校验容差或执行限额。
## 基线与许可
`baseline/` 为 SakanaAI/ShinkaEvolve 官方代码,Apache-2.0(见 `baseline/LICENSE`),
固定提交与 sha256 见 `baseline/SOURCES.md`。基线的嵌套缩半径循环与退化布局是
刻意保留的原始事实,不得"修复"——任何改进必须作为正式候选经过研究流程。
## 依赖
Python 3.13。正式实验环境里装的第三方库**只有下面这些**,版本与 `requirements.txt` 逐行一致:
| 包 | 版本 | 用途 |
|---|---|---|
| numpy | 2.3.1 | 数组与矩阵运算 |
| scipy | 1.18.1 | 数值优化、线性规划、空间距离计算等 |
| numba | 0.67.0 | 把 Python 循环编译成机器码 |
| llvmlite | 0.49.0 | numba 的编译后端 |
| sympy | 1.14.0 | 符号与精确计算 |
| mpmath | 1.3.0 | 任意精度浮点运算(sympy 依赖它) |
| networkx | 3.7 | 图结构分析 |
**依赖只有镜像里的这几个包。**运行时不能再装别的包:正式运行在固定的镜像里跑、容器断网,
你在工作目录里装的包进不了正式运行。
**每次运行只有 1 核。**容器按 `--cpus=1` 限总量,环境里 `PYTHON_CPU_COUNT=1`,
所以 `os.cpu_count()`、`multiprocessing.cpu_count()` 都报 1。多开进程或线程不会更快,
只会互相抢这 1 个核、少干活。数学库(`OMP_NUM_THREADS`)和 numba(`NUMBA_NUM_THREADS`)的线程数都固定为 1。numba 首次编译函数的时间算在这次运行的墙钟里。
**环境由镜像钉死。**任务跑在 `Dockerfile` 建出来的镜像里,这些库都烧在那个镜像里。
镜像在研究开始时建一次,镜像编号冻结进账本;一份研究不能自己改依赖。
(给维护者:`Dockerfile` 的基础镜像经 `dockerproxy.net` 拉取,换机器时可能要改这一行。)
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Sakana AI
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+34
View File
@@ -0,0 +1,34 @@
# 官方基线来源声明
本目录文件来自 SakanaAI/ShinkaEvolve,Apache-2.0 许可(见同目录 `LICENSE`)。
- 固定提交:`9912af12d423504b8d580f4179fd15f5f88b8c50`
- 固定日期:2026-09-17(由项目负责人原字节下载并哈希固定在 `/tmp/rivalbits2-shinka-18k23a46/manifest.json`)
| 文件 | 原始 URL | sha256 | 修改 |
| --- | --- | --- | --- |
| `initial.py` | <https://raw.githubusercontent.com/SakanaAI/ShinkaEvolve/9912af12d423504b8d580f4179fd15f5f88b8c50/examples/circle_packing/initial.py> | `09da8428a85792a94a9c6cc2193b6a0db1090ce598a17b75bc0db48d0953d098` | 无(逐字节复制) |
| `evaluate.py` | <https://raw.githubusercontent.com/SakanaAI/ShinkaEvolve/9912af12d423504b8d580f4179fd15f5f88b8c50/examples/circle_packing/evaluate.py> | `eb8b9787dc25dee24a5f5305d1e3bd876e5c336cded1c9bab7fb204612928b4d` | 无(逐字节复制) |
| `LICENSE` | <https://raw.githubusercontent.com/SakanaAI/ShinkaEvolve/9912af12d423504b8d580f4179fd15f5f88b8c50/LICENSE> | `c3e5b1c26607caf7208d5a105c113fe240630bb873fdb8ca5b4c2e32c721ddaf` | 无 |
上表三个 sha256 是基线"没被改过"的唯一判据,由本项目的 `tests/tasks.test.ts`
("基线三个文件的 sha256 等于 SOURCES.md 表上的值")逐个断言。
它防的是**别的测试看不见的改动**:给 `initial.py` 加一行注释、或者动一个字节的
`evaluate.py` / `LICENSE`,成绩一位不变,别的测试都不会挂。`evaluate.py` 和 `LICENSE`
根本不在运行路径上。
一个实测过的事实(参考项目里跑出来的):把 `compute_max_radii` 那个白跑 105625 次的冗余双循环删掉,
半径和一位不差(都是 0.9597642169962064),但第 (7, 8) 对圆会重叠 2.7755575615628914e-17,
容差 0 下判不合法,要经合法化才收下。那个冗余循环仍然是本研究的改进起点。
## 已知的基线事实(刻意保留,不是缺陷)
- `initial.py` 的 `compute_max_radii` 含嵌套的冗余缩半径循环(外层 `for i/for j`
包了一层重复的 `for i/for j`),且外环布局(0.7 半径环经 clip 到 0.99)是退化布局;
圆心 clip 后可能出现零半径。这些是官方基线的原样事实,是本研究的改进起点,
**不得“修复”**——任何改进必须作为正式候选经过研究流程。
- `evaluate.py` 依赖 `shinka.core.run_shinka_eval`(ShinkaEvolve 框架内部),
且其聚合以候选自报的 `reported_sum` 作为 `combined_score`。本项目**不信任**
自报分数:独立校验器 `../validate.py` 不 import 本文件,自行重算半径和。
本文件仅作为官方协议参考保留,不在本项目的运行路径上执行。
+197
View File
@@ -0,0 +1,197 @@
"""
Evaluator for circle packing example (n=26) with improved timeout handling
"""
import os
import argparse
import numpy as np
from typing import Tuple, Optional, List, Dict, Any
from shinka.core import run_shinka_eval
def format_centers_string(centers: np.ndarray) -> str:
"""Formats circle centers into a multi-line string for display."""
return "\n".join(
[
f" centers[{i}] = ({x_coord:.4f}, {y_coord:.4f})"
for i, (x_coord, y_coord) in enumerate(centers)
]
)
def adapted_validate_packing(
run_output: Tuple[np.ndarray, np.ndarray, float],
atol=0.0,
) -> Tuple[bool, Optional[str]]:
"""
Validates circle packing results based on the output of 'run_packing'.
Args:
run_output: Tuple (centers, radii, reported_sum) from run_packing.
Returns:
(is_valid: bool, error_message: Optional[str])
"""
centers, radii, reported_sum = run_output
msg = "The circles are placed correctly. There are no overlaps or any circles outside the unit square."
if not isinstance(centers, np.ndarray):
centers = np.array(centers)
if not isinstance(radii, np.ndarray):
radii = np.array(radii)
n_expected = 26
if centers.shape != (n_expected, 2):
msg = (
f"Centers shape incorrect. Expected ({n_expected}, 2), got {centers.shape}"
)
return False, msg
if radii.shape != (n_expected,):
msg = f"Radii shape incorrect. Expected ({n_expected},), got {radii.shape}"
return False, msg
if (
not np.all(np.isfinite(centers))
or not np.all(np.isfinite(radii))
or not np.isfinite(reported_sum)
):
msg = "Non-finite values found in centers, radii, or reported_sum."
return False, msg
if np.any(radii < 0):
negative_indices = np.where(radii < 0)[0]
msg = f"Negative radii found for circles at indices: {negative_indices}"
return False, msg
if not np.isclose(np.sum(radii), reported_sum, atol=atol):
msg = (
f"Sum of radii ({np.sum(radii):.6f}) does not match "
f"reported ({reported_sum:.6f})"
)
return False, msg
for i in range(n_expected):
x, y = centers[i]
r = radii[i]
is_outside = (
x - r < -atol or x + r > 1 + atol or y - r < -atol or y + r > 1 + atol
)
if is_outside:
msg = (
f"Circle {i} (x={x:.4f}, y={y:.4f}, r={r:.4f}) is outside unit square."
)
return False, msg
for i in range(n_expected):
for j in range(i + 1, n_expected):
dist = np.sqrt(np.sum((centers[i] - centers[j]) ** 2))
if dist < radii[i] + radii[j] - atol:
msg = (
f"Circles {i} & {j} overlap. Dist: {dist:.4f}, "
f"Sum Radii: {(radii[i] + radii[j]):.4f}"
)
return False, msg
return True, msg
def get_circle_packing_kwargs(run_index: int) -> Dict[str, Any]:
"""Provides keyword arguments for circle packing runs (none needed)."""
return {}
def aggregate_circle_packing_metrics(
results: List[Tuple[np.ndarray, np.ndarray, float]], results_dir: str
) -> Dict[str, Any]:
"""
Aggregates metrics for circle packing. Assumes num_runs=1.
Saves extra.npz with detailed packing information.
"""
if not results:
return {"combined_score": 0.0, "error": "No results to aggregate"}
centers, radii, reported_sum = results[0]
public_metrics = {
"centers_str": format_centers_string(centers),
"num_circles": centers.shape[0],
}
private_metrics = {
"reported_sum_of_radii": float(reported_sum),
}
metrics = {
"combined_score": float(reported_sum),
"public": public_metrics,
"private": private_metrics,
}
extra_file = os.path.join(results_dir, "extra.npz")
try:
np.savez(
extra_file,
centers=centers,
radii=radii,
reported_sum=reported_sum,
)
print(f"Detailed packing data saved to {extra_file}")
except Exception as e:
print(f"Error saving extra.npz: {e}")
metrics["extra_npz_save_error"] = str(e)
return metrics
def main(program_path: str, results_dir: str):
"""Runs the circle packing evaluation using shinka.eval."""
print(f"Evaluating program: {program_path}")
print(f"Saving results to: {results_dir}")
os.makedirs(results_dir, exist_ok=True)
num_experiment_runs = 1
# Define a nested function to pass results_dir to the aggregator
def _aggregator_with_context(
r: List[Tuple[np.ndarray, np.ndarray, float]],
) -> Dict[str, Any]:
return aggregate_circle_packing_metrics(r, results_dir)
metrics, correct, error_msg = run_shinka_eval(
program_path=program_path,
results_dir=results_dir,
experiment_fn_name="run_packing",
num_runs=num_experiment_runs,
get_experiment_kwargs=get_circle_packing_kwargs,
validate_fn=adapted_validate_packing,
aggregate_metrics_fn=_aggregator_with_context,
)
if correct:
print("Evaluation and Validation completed successfully.")
else:
print(f"Evaluation or Validation failed: {error_msg}")
print("Metrics:")
for key, value in metrics.items():
if isinstance(value, str) and len(value) > 100:
print(f" {key}: <string_too_long_to_display>")
else:
print(f" {key}: {value}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Circle packing evaluator using shinka.eval"
)
parser.add_argument(
"--program_path",
type=str,
default="initial.py",
help="Path to program to evaluate (must contain 'run_packing')",
)
parser.add_argument(
"--results_dir",
type=str,
default="results",
help="Dir to save results (metrics.json, correct.json, extra.npz)",
)
parsed_args = parser.parse_args()
main(parsed_args.program_path, parsed_args.results_dir)
+100
View File
@@ -0,0 +1,100 @@
# EVOLVE-BLOCK-START
"""Constructor-based circle packing for n=26 circles"""
import numpy as np
def construct_packing():
"""
Construct a specific arrangement of 26 circles in a unit square
that attempts to maximize the sum of their radii.
Returns:
Tuple of (centers, radii, sum_of_radii)
centers: np.array of shape (26, 2) with (x, y) coordinates
radii: np.array of shape (26) with radius of each circle
sum_of_radii: Sum of all radii
"""
# Initialize arrays for 26 circles
n = 26
centers = np.zeros((n, 2))
# Place circles in a structured pattern
# This is a simple pattern - evolution will improve this
# First, place a large circle in the center
centers[0] = [0.5, 0.5]
# Place 8 circles around it in a ring
for i in range(8):
angle = 2 * np.pi * i / 8
centers[i + 1] = [0.5 + 0.3 * np.cos(angle), 0.5 + 0.3 * np.sin(angle)]
# Place 16 more circles in an outer ring
for i in range(16):
angle = 2 * np.pi * i / 16
centers[i + 9] = [0.5 + 0.7 * np.cos(angle), 0.5 + 0.7 * np.sin(angle)]
# Additional positioning adjustment to make sure all circles
# are inside the square and don't overlap
# Clip to ensure everything is inside the unit square
centers = np.clip(centers, 0.01, 0.99)
# Compute maximum valid radii for this configuration
radii = compute_max_radii(centers)
return centers, radii
def compute_max_radii(centers):
"""
Compute the maximum possible radii for each circle position
such that they don't overlap and stay within the unit square.
Args:
centers: np.array of shape (n, 2) with (x, y) coordinates
Returns:
np.array of shape (n) with radius of each circle
"""
n = centers.shape[0]
radii = np.ones(n)
# First, limit by distance to square borders
for i in range(n):
x, y = centers[i]
# Distance to borders
radii[i] = min(x, y, 1 - x, 1 - y)
for i in range(n):
for j in range(i + 1, n):
dist = np.sqrt(np.sum((centers[i] - centers[j]) ** 2))
# Then, limit by distance to other circles
# Each pair of circles with centers at distance d can have
# sum of radii at most d to avoid overlap
for i in range(n):
for j in range(i + 1, n):
dist = np.sqrt(np.sum((centers[i] - centers[j]) ** 2))
# If current radii would cause overlap
if radii[i] + radii[j] > dist:
# Scale both radii proportionally
scale = dist / (radii[i] + radii[j])
radii[i] *= scale
radii[j] *= scale
# Ensure no negative radii due to numerical issues
radii = np.maximum(radii, 0.0)
return radii
# EVOLVE-BLOCK-END
# This part remains fixed (not evolved)
def run_packing():
"""Run the circle packing constructor for n=26"""
centers, radii = construct_packing()
# Calculate the sum of radii
sum_radii = np.sum(radii)
return centers, radii, sum_radii
+100
View File
@@ -0,0 +1,100 @@
"""候选代码的轻量静态检查(不执行候选的任何一行代码)。
实验员需要一个便宜的办法确认代码能跑;不给他这个办法,他就会自己跑一遍完整实验
去验证。这个脚本就是那个便宜的办法:只做三件事,全部基于抽象语法树。
1. 入口文件在不在
2. 候选目录下每个 ``*.py`` 能不能 ``ast.parse``
3. 只对入口文件:顶层恰好有一个叫 ``run_packing`` 的函数,且零个必填参数
用法:``python -B check-candidate.py <候选目录> <入口文件相对候选目录的路径> <结果文件>``。
结果写进 <结果文件>,一个 JSON 对象:``{"valid": bool, "reasons": [...]}``。
对候选代码的每一种意见都走"退出码 0 加 reasons";非零退出说明这个脚本自己没跑成。
两条调用约定,改的时候不要破坏:
- **本文件不能放进候选目录。** 第 2 步从候选目录往下扫 ``*.py``,
脚本自己在里面就会把自己也扫进去。宿主把它放在候选目录的上一层。
- **只用标准库,``-B`` 要保留。** 不写 ``__pycache__``:检查本身不该往候选目录里添文件。
"""
import ast
import json
import pathlib
import sys
ENTRY_FUNCTION = "run_packing"
# 两种定义形式都要认,否则 `async def run_packing()` 会被判成"不存在"。
FUNCTION_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
def has_zero_required_arguments(node) -> bool:
"""判断函数是不是可以零参数调用。
位置参数(含仅位置参数)里没有默认值的那些必须为 0 个;
仅关键字参数里 kw_defaults 为 None 的表示没有默认值,一个都不能有。
"""
positional = len(node.args.posonlyargs) + len(node.args.args)
required_positional = positional - len(node.args.defaults)
keyword_without_default = any(
default is None for default in node.args.kw_defaults
)
return required_positional <= 0 and not keyword_without_default
def main() -> int:
if len(sys.argv) != 4:
print(
"用法: check-candidate.py <候选目录> <入口文件相对路径> <结果文件>",
file=sys.stderr,
)
return 2
root = pathlib.Path(sys.argv[1])
entry_point = sys.argv[2]
reasons: list[str] = []
if not (root / entry_point).is_file():
reasons.append(f"入口文件不存在: {entry_point}")
# 第 3 步挂在第 2 步的 rglob("*.py") 上:入口文件名不以 .py 结尾,
# 循环扫不到它,入口函数那条判定会整个被跳过,却照样报通过。
if not entry_point.endswith(".py"):
reasons.append(
f"入口文件必须以 .py 结尾,否则入口函数检查扫不到它: {entry_point}"
)
for path in root.rglob("*.py"):
relative = str(path.relative_to(root))
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=relative)
except (SyntaxError, ValueError) as error:
# SyntaxError:语法错;ValueError:含空字节或不是 UTF-8(UnicodeDecodeError 是它的子类)。
reasons.append(f"{relative} 解析失败: {error}")
continue
if relative != entry_point:
continue
candidates = [
node
for node in tree.body
if isinstance(node, FUNCTION_NODES) and node.name == ENTRY_FUNCTION
]
if len(candidates) != 1:
reasons.append(
f"{entry_point} 顶层的 {ENTRY_FUNCTION}() 应当恰好有一个定义,"
f"实际有 {len(candidates)} 个"
)
elif not has_zero_required_arguments(candidates[0]):
reasons.append(
f"{entry_point} 的 {ENTRY_FUNCTION}() 必须能零参数调用,"
f"当前有必填参数"
)
with open(sys.argv[3], "w", encoding="utf-8") as handle:
json.dump({"valid": not reasons, "reasons": reasons}, handle)
return 0
if __name__ == "__main__":
sys.exit(main())
+198
View File
@@ -0,0 +1,198 @@
"""填圆任务的运行 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())
+7
View File
@@ -0,0 +1,7 @@
numpy==2.3.1 # 数组与矩阵运算
scipy==1.18.1 # 数值优化、线性规划、空间距离计算等
numba==0.67.0 # 把 Python 循环编译成机器码
llvmlite==0.49.0 # numba 的编译后端
sympy==1.14.0 # 符号与精确计算
mpmath==1.3.0 # 任意精度浮点运算(sympy 依赖它)
networkx==3.7 # 图结构分析
+8
View File
@@ -0,0 +1,8 @@
{
"kind": "circle_packing",
"finalMetric": { "id": "sum-of-radii", "direction": "maximize" },
"epsilon": 0.01,
"knownBest": 2.635983,
"run": { "wallSeconds": 120, "cores": 1, "runtime": "runc" },
"protectedFiles": ["harness.py", "validate.py", "check-candidate.py", "metrics-spec.json", "solution.json", "evaluate.py", "LICENSE", "SOURCES.md"]
}
+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())