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:
@@ -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
|
||||
@@ -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` 拉取,换机器时可能要改这一行。)
|
||||
@@ -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.
|
||||
@@ -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 本文件,自行重算半径和。
|
||||
本文件仅作为官方协议参考保留,不在本项目的运行路径上执行。
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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 # 图结构分析
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user