跳转至

差分测试基础设施(全文)

每一条判据守的是什么、怎么证明它不会空过、覆盖多少。仓库里的工程记录原文,中文。

源文件:harness/README.md

每一层都要能对全量语料跑一遍逐字段比对。"分歧数"是头号 KPI;目标不是 0, 而是"接近 0 + 每条剩余分歧都有归档结论"。

组件

文件 作用 来源
gen_elements.py 生成元素表 → crates/omgkit-core/src/element_data.rs
oracle_pipeline.py 生成分层基准(JSONL)
check_write.py 用外部实现裁判 SMILES 写出(见下)
oracle_smarts.py 生成 SMARTS 解析基准,并抽取 SMARTS 语料
corpus/smoke.smi 冒烟语料(149 条),覆盖主要语法陷阱与各步骤的触发用例 本项目自写
corpus/large.smi 大语料(8839 条) RDKit 测试数据(NCI / ZINC / canonSmiles),见 ../THIRD-PARTY-NOTICES.md
corpus/smarts.txt SMARTS 语料(776 条) RDKit 的 PAINS / 官能团层级 / RLewis 库,同上
check_product_chirality.py 裁判产物侧手性(见下)。用例写死在文件里 —— 反应语料在这条路上触发面是 0
compare_rdkit.py 把 omgkit 与 RDKit 画的同一个分子并排出图,供人眼比对
make_gallery.py 把上面那批并排图拼成 gallery.pdf,供人眼一次翻完
corpus/reactions.txt 反应模板语料(20 条),没有一条在产物侧写手性 本项目自写
check_descriptors.py 用外部实现裁判图特征描述符(十六项,逐原子逐键)
gen_palette.py 生成 CPK 配色表 → crates/omgkit-depict/src/palette_data.rs
params/jmol_colors.tsv Jmol 的 CPK 元素配色(109 元素) Jmol 的表,经 3Dmol.js 与 ASE 两份独立转录逐项核对
check_depict3d.py 裁判三维分子图 —— 从产品吐出的 SVG 反读圆与线(见下)
check_python_depict3d.py 三维图的 Python 绑定与 Rust 侧逐字节比
corpus/descriptors.smi 描述符判据的边界语料(15 条),只装前三份语料走不到的那三档 本项目自写,理由写在文件头
baseline/ 生成的基准 —— 跑外部实现产出的,不是抄来的文件 派生数据

进这个目录的门槛:产出一个能回归的裁定。 上面每一个 oracle_* / check_* 都给出一个分歧数,数字变大就是回归;gen_elements.py 产出的是源码,重跑一遍 必须逐字节相同。不满足这条的东西不该放这里 —— 它只会让"harness 里的都跑绿了" 这句话变得不值钱。

compare_rdkit.py 是这条线上的例外,得说清楚:它不产出裁定,只把两边画的 同一个分子并排出图给人看。留着它是因为出图这一层确实有判据守不住的一半("这张 图读起来顺不顺"),而它把两边的键长压到同一尺度这件事是有内容的、值得留存的。 但它不是判据,别把它跑绿了当成证据。

语料一律取自外部、不手挑:手挑的分子与模式会不自觉地只挑实现已经处理得了 的写法。smoke.smi / hard.smi / descriptors.smi 三份是例外,而且是同一种 例外:它们照着算法的假设挑,每一行攻击一条具体的分支,不是"挑几个我们跑得过 的分子"。判断标准是"这一行进来之前,哪一条判据是空过的" —— 答不上来就不该加。 代价是随仓库分发它们要交代清楚出处与条款,那份对照表就是 ../THIRD-PARTY-NOTICES.md,里面附了"有没有来路 不明的条目"的核对命令。

用法

# 重新生成元素表
python3 harness/gen_elements.py --rdkit ../rdkit \
    --out crates/omgkit-core/src/element_data.rs

# 生成基准。l1/l2 必须保持 --remove-hs 关闭(默认);l3 要打开
python3 harness/oracle_pipeline.py --input harness/corpus/smoke.smi \
    --stage l1 --out harness/baseline/smoke.l1.jsonl
python3 harness/oracle_pipeline.py --input harness/corpus/smoke.smi \
    --stage l3 --remove-hs --out harness/baseline/smoke.l3.jsonl

# l3 的大语料档(1.5 M,按 .gitignore 的规矩不入库),
# 给 differential_l3.rs 里那条 #[ignore] 的测试用
python3 harness/oracle_pipeline.py --input harness/corpus/large.smi \
    --stage l3 --remove-hs --out harness/baseline/large.l3.jsonl

--remove-hs 在 l3 上是净化之后才跑的(Chem.RemoveHs),不是解析那一步。 放在解析那一步会与 sanitize=False 撞上:RDKit 会把方括号里的氢数、 noImplicit 标志和手性标记一并抹掉 —— 实测 [C@@H]1CCCCC1O 的规范串从 OC1[CH]CCCC1 变成 OC1CCCCC1,2-羟基环己基自由基变成了环己醇, 三条刻意造的手性用例全中招。这与本文件开头那句"omgkit 把 removeHs 划为 独立操作,不属于 L2"是同一件事:去氢是净化之后的一步。

SMARTS 基准

python3 harness/oracle_smarts.py --build-corpus          # 重建语料(需数据文件)
python3 harness/oracle_smarts.py --out harness/baseline/smarts.jsonl

比三件事:可解析性原子数键数。只比可解析性最危险 —— 一个把所有内容都当通配符的实现能 100% 通过。语料不手挑:手挑会不自觉地 只挑自己已经实现的写法,而真实语料里 =!@$(...) 这类正是最容易漏的。

三条必须遵守的操作约定

1. 参考实现的源码版本必须与已安装的包一致。 不一致时基准与元素表会来自 不同版本,产生无法解释的假分歧。gen_elements.py 会硬性检查并拒绝执行。

2. 不要在参考实现的源码目录下运行。 那里的同名包目录会遮蔽已安装的包, 报 partially initialized moduleoracle_pipeline.py 会检测并给出可读报错。

3. 逐条写命令,不要用 shell 循环拼 --sanitize-ops 循环里的变量会被 词分割,拼出来的 ops 与预期不同,而基准照样生成得出来 —— 失败是静默的。

removeHs 不属于净化

MolFromSmiles(smi) 做的是解析 + 净化 + removeHs 三件事,最后一步会删掉 显式 [H] 原子并改变原子数。本项目把 removeHs 划为净化之外的独立操作, 所以 l1/l2 基准必须关掉它,否则两层的图对不齐,后续每一层都要背这个错位。

--sanitize-ops:逐步验证

净化有 12 步。拿"12 步全跑完"的基准去验证单独某一步是行不通的 —— 芳香性感知会改写芳香标志,收尾还会重算一次价键,凯库勒式写法的分子在 "只跑第 3 步"和"全跑完"两种状态下隐式氢/显式价并不相同。

# 只跑第 3 步
python3 harness/oracle_pipeline.py --in harness/corpus/smoke.smi --stage l2 \
    --sanitize-ops PROPERTIES --out harness/baseline/smoke.l2-properties.jsonl

# 跑到第 4 步为止(注意前缀里有第 2 步,见下)
python3 harness/oracle_pipeline.py ... \
    --sanitize-ops CLEANUP,CLEANUP_ORGANOMETALLICS,PROPERTIES,SYMMRINGS ...

前缀式基准必须带上第 2 步

第 2 步(CLEANUP_ORGANOMETALLICS)是净化里唯一会改动图的一步 —— 它把超价 非金属与金属之间的单键改成配位键并交换端点。少了它,有机金属分子在基准侧 会在第 3 步就判超价失败,而本实现侧照常算下去,表现为一条"分歧", 根因却只是两边跑的步骤不同。这类假分歧最容易被误读成实现问题。

所以 l2-rings 及其之后的每一档基准,ops 都以 CLEANUP,CLEANUP_ORGANOMETALLICS 开头,对应测试的管线也要调 cleanup_organometallics

l2-cleanupl2-properties刻意孤立某一步的档,不加。

可用名称:CLEANUP CLEANUP_ORGANOMETALLICS PROPERTIES SYMMRINGS KEKULIZE FINDRADICALS SETAROMATICITY SETCONJUGATION SETHYBRIDIZATION CLEANUPATROPISOMERS CLEANUPCHIRALITY ADJUSTHS

部分运行时环信息可能未初始化,此时"最小环大小"列填 -1(区别于 0 = 不在 环中),让误比对立刻炸出来而不是悄悄比了个假值。

净化对图的影响

在 8839 条语料上核实过:

结果
原子数/键数改变 0
无向边集改变 0
键端点朝向/键型改变 1 例(第 2 步把某键改成配位键并交换端点)

这是"在解析结果的图上直接做纯图算法(如环感知)"成立的前提。

写出的裁判:check_write.py

往返测试(cargo test --test roundtrip_smiles)只用自家解析器验证写出, 原理上留了一个漏网可能:解析与写出共享同一个误解,互为逆运算却都偏离 SMILES 的语义。手性尤其危险 —— 标记写反了,原子数、键集合、连通性全都对, 只有分子是镜像的,纯拓扑比对永远发现不了。

第二个参数是同一份语料,判据拿它核分母:

# 按存储顺序写出
cargo run --release -p omgkit-io --example write_smiles -- \
    harness/corpus/large.smi > /tmp/written.tsv
python3 harness/check_write.py /tmp/written.tsv harness/corpus/large.smi

# 规范 SMILES(经规范化排序)
cargo run --release -p omgkit-io --example write_smiles -- \
    harness/corpus/large.smi --canonical > /tmp/canon.tsv
python3 harness/check_write.py /tmp/canon.tsv harness/corpus/large.smi

两边各自规范化再比字符串,判官与本实现无共谋。当前结果(2026-08-26 实测, CI 钉的 RDKit 2025.09.2,三档都开 --strict):

大语料 大语料(规范) 冒烟
规范形式逐条相同 8831 8831 137
仅配位几何立体不同(已登记) 0 0 0
真分歧 0 0 0
语料行 / 写出行 / 真正比对 8839 / 8839 / 8831 8839 / 8839 / 8831 149 / 141 / 137

冒烟那一档先前是 6 条(配位几何写不出来,分桶豁免掉了)。三类几何都写得出来 之后归零,豁免桶随之空掉 —— 三档现在都跑 --strict,桶里再进东西就是失败。

"没比到"的那几条要看清是哪一路,它们是判据的分母闸(MAX_UNCOMPARED):

  • 大语料 8 条 = 外部实现读不了原串(CC1=[O+][Be]2…[Fe++] 的二茂铁、 [Co]/[Al+3]/[Hg] 配合物、CCO1=O=C1 等),两侧都没有结果;
  • 冒烟档 12 条 = 8 条故意构造的解析失败用例 + 4 条判官读不了。

这个数跟 RDKit 版本走:2022.09.5 上大语料是 6 条,2025.09.2 是 8 条 (它对 Al/Si/P 的超价收得更紧)。上限 15 是按实测最大值 12 加余量定的。

先前这条判据喂一个空文件进去照样打印"零分歧"退 0;而头一版分母闸数的是 TSV 行数,行数掉不下来 —— 独立审核实测把 19 行换成垃圾,"逐条相同"从 8833 悄悄掉到 8814 而判据仍退 0。现在数的是真正比对过的分子数

规范那一列先前是红的,而这张表三格全写着 0 —— 一个过期的绿。 缺陷不在判官,在 omgkit-io 的规范写出:它把超价原子的方括号丢了, 于是外部读者按下一个允许价补氢,读出来是另一个分子:

改前写出 Cl[I]Cl  →  ClICl               RDKit 读回 Cl[IH]Cl   ← 多了一个 H
改前写出 NC[S](=O)=O → NCS(=O)=O          RDKit 读回 NC[SH](=O)=O
改前写出 F[P](F)(F)(F)(F)F → FP(F)...     RDKit 读回 F[PH](F)(F)(F)(F)F

根因不是与外部实现的约定分歧,是我们自己两处规则不一致:补氢取的是 "第一个不小于已用价的允许价"(碘的价表 [1,3,5],两根键补到 3 价), 而写出侧只看首位默认价,断定"2 ≥ 1,不用留框"。往返测试抓不住 —— io 的解析器根本不推隐式氢,两边共谋。

已修(hs_survive_without_brackets 改用同一条规则,超价一律留框), 全语料输出只改了 10 条,按存储顺序那一列一条没动。Rust 侧补了 crates/omgkit-chem/tests/canonical_write_survives_resanitize.rs(变异验证过), 这条判据的两个方向也都进了 CI

这条判官先前不在 CI 里,所以红了很久没人知道。与 check_wedge_readback.py 那次是同一个病:没人跑的判据,红着也没人知道。

尚未写出的立体信息按类分桶,不算失败:抹掉该类之后若完全一致,差别就 确实只在那一类。这个豁免现在是空的 —— SMILES 能表达的立体信息(四面体、 双键顺反、三类配位几何、丙二烯轴手性)全都写得出来了,三档判据一律加 --strict,桶里再进东西直接算失败。桶本身留着:再有新的立体类别时还要用它过渡。

比对时关掉 removeHs:它会删掉显式 [H],而带立体信息的 [H] 会被 保留 —— 于是"某类立体没写出"会间接表现为原子数不同,把一个已登记的缺口 伪装成结构错误。

列规范 —— 哪些列在哪一层比对

字段含义跨层错配会制造成千上万条幻影分歧,且极难定位。新增列时必须同步 更新此表。

原子列

# 字段 l1 l2 说明
0 原子序数
1 形式电荷
2 同位素 0 = 未指定
3 显式氢 仅方括号原子有意义
4 映射号 反应用;0 = 无
5 立体类别 几何类别,不是 CIP 的 R/S;编码见下
6 芳香 l1 是小写字母的字面声称,l2 是感知结果
7 不推断隐式氢 方括号原子恒为真
8 隐式氢 价键推断的产出
9 显式价
10 杂化
11 在环中
12 最小环大小 0 = 不在环中,-1 = 环感知未运行
13 自由基电子数 第 6 步的产出
8 / 14 立体排列序号 不比 不比 @TB15 里的 15;四面体恒为 0,理由见下

新列一律追加到行尾,这就是"立体排列序号"在两个阶段列号不同的原因: l1 行只有 8 列核心列,l2 行在核心列之后还插了 6 列自己的。若把新列插在 核心列之内,l2 独有的那 6 列会整体后移,而各差分测试的列号是硬编码的 —— 后果是静默比错字段,而不是报错。

每个差分测试都有一条 A_COLS 断言钉住行长(l1 = 9,l2 = 15),基准与测试 的列号一旦脱节就当场炸,而不是变成一堆无从解释的"化学分歧"。这条断言不是 可有可无的:在 l1 的第 8 列插一列恒为零的列,8839 条大语料差分照样全绿 —— 因为那一列本来就几乎全是零。列错位的失败模式就是这么安静。

立体排列序号不逐值比对

第 8 / 14 列两侧记的是两个不同的量:基准存的是归一到分子键序之后的序号 (配体缺位还会补占位),本实现存的是书写时的字面值。二者只在"没有重排且 配体齐全"时才碰巧相等。

归一要走一张查找表 —— 序号枚举的是配体在多面体位置上的排法(模掉该多面体 的转动群),邻居每做一次对换,序号就按表跳到另一个值。这属于 L6。在那之前 这一列照常记录(留着给 L6 做参照),但差分测试跳过它。

键列

# 字段 l1 l2 说明
0 起点
1 终点
2 键级 编码见下
3 方向 / = 1,\ = 2
4 芳香 l1 是小写字母的字面声称
5 在环中 不比 见下方陷阱
6 共轭 不比 共轭感知属于净化;l1 比它等于把净化的缺陷记到解析头上

键行同样有一条 B_COLS = 7 断言,七个读取方各一条 —— 这是补上的: 原子那侧九个读取方全有,键这侧一个都没有。实测把 l1 基准的键元组截掉 最后一列(7 → 6),differential_l1 退 0、照印"零分歧"(它只索引前 5 列), 而 l2 那几个会以下标越界 panic 收场 —— 两种都不是"基准过期了"这句话。 加上之后:退 101,打印"基准的键列数是 6,本文件按 7 列解读"。

顺带纠正:这张表先前只到第 5 列,而基准一直是 7 列;differential_l1.rs 的 注释也写着 6 列。那第 7 列(共轭)导了、l2 两个读取方在比,却没写进规范表。

环集(仅 l2)

rings 字段:每个环一个已排序的原子下标列表;环感知未运行时为 null (区别于 [] = 确实没有环)。

刻意排序:环内原子的排列是遍历产物,只有原子集合是语义量。比对必须按集合。

陷阱:l1 的"在环中"列不可比对

MolFromSmiles(..., sanitize=False) 之后 RingInfo.NumRings()RuntimeError,但 bond.IsInRing() 仍返回真实的环成员信息 —— 解析器对 未净化分子会自行找一次环。

而本项目把环感知划归净化,解析只做纯解析。所以 l1 比对必须跳过这一列, 到 l2 再比。不跳过的话,解析层会被迫提前实现环感知,层次就塌了。

编码表

必须与 omgkit-core#[repr(u8)] 判别值逐一对应。任何一处对不上,差分 测试会把编码错误报成化学错误。

键级 立体类别 方向
Unspecified 0 Unspecified 0 None 0
Single 1 四面体 CW (@@) 1 / 1
Double 2 四面体 CCW (@) 2 \ 2
Triple 3 丙二烯轴手性 (@AL) 3
Quadruple 4 平面四方 (@SP) 4
Aromatic 5 三角双锥 (@TB) 5
Dative 6 八面体 (@OH) 6

判据的选择:语义量 vs 实现痕迹

比对之前必须先问:这个量是由分子决定的,还是遍历顺序留下的?

拿实现痕迹当判据会产生大量假失败,并把实现推向错误的方向 —— 为了对齐一个 没有意义的量,去复刻另一份实现的控制流。已确认的分类:

性质 处理
逐键下标的键级(kekulize 后) 痕迹 —— 多个 Kekulé 式都合法 不比对;改比每原子双键数
环内原子的排列顺序 痕迹 归一成集合再比
每原子的显式价 / 隐式氢 语义 逐项比对
环的原子集合 语义 —— 对原子重排不变 逐项比对

判定方法:把原子编号随机重排,看结果变不变。 不变的才是语义量。

触发面窄的步骤:光测"零分歧"是不够的

有些步骤只在极少数分子上生效:

步骤 生效分子数 / 8839
第 1 步(非标准画法修正) 15
第 2 步(有机金属键改配位键) 2
第 6 步(自由基) 9
相关环比最小环基多出环 49

一个什么都不做的空实现同样能通过"零分歧"断言 —— 因为其余分子本来就不该 变。所以这类步骤必须额外断言"它确实改动了预期数量的分子",并保证冒烟语料 里含有能触发它的用例。

任何"只在少数输入上生效"的变换都适用:先确认它会开火,再确认它开得准。

已知分歧的处理方式

差分测试用 KNOWN_DIVERGENCES 登记已定位根因的分歧。它不是豁免名单:

  • 未登记的分歧 → 测试失败
  • 已登记但已不再分歧的条目 → 测试也失败,并要求删除该条目

第二条是关键:一个永远躺着没人动的豁免名单本身就是暗坑。当前所有登记表都是 空的。

复杂度守卫

差分测试抓不到复杂度问题 —— 结果全对,只是慢,而且在小分子上完全看不出来。 crates/omgkit-chem/tests/scaling.rs 专门盯增长曲线,判据是每原子耗时不随 规模上升

已知的复杂度陷阱都是同一个形状:在"按原子/按环系"的循环里,做了一件正比于 整个分子的事。

编写这类测试有三条硬要求:

  1. 规模要够。 平方项要到几千原子才占主导,规模太小时缺陷淹没在噪声里。
  2. 交错测量。 顺序测(小的先、大的后)时最小档会系统性偏慢 —— 线程起初 落在能效核上、频率还没爬满 —— 足以把曲线整个翻过来。
  3. 形状要够。 分量内算法的复杂度只在"单个大稠合体系"上暴露;"很多个小 环系"那种形状里每个分量才 6 个原子,没有生长空间。

阈值必须拿真实缺陷标定:把已知的平方项逐个塞回代码,确认测试真的会红。 一条从未验证过会失败的定时测试,等于没有。

三条复杂度判据现在都数工作量,墙钟只当粗闸

crate 判据 数的是
omgkit-match 子结构匹配(两维) SearchStats::candidate_tests
omgkit-match 副产物收口 CloseStats::site_visits / fragment_scans
omgkit-chem 环搜索(三种形状) SearchStats::bfs_visits / edge_tests / path_steps

三处都是:钉死绝对值(主判据,变小也红)+ 每单位工作量的涨幅(守只在大规模 上冒头的退化)+ 3 倍的墙钟粗闸(守计数看不见的那一半:每次操作本身变贵了)。

收口那一条实测 site_visits 恰好 2n + 1,debug 与 release 逐位相同 —— 换掉墙钟之后这一档连"抖"都没有了。

墙钟之外还要数工作量

omgkit-chem/tests/scaling.rs 里另有一条 ring_search_work_is_pinned_per_shape, 判的是 sssr::ring_set_counted 报的整数计数(BFS 出队次数、考察过的 (起点, 边)对数、回溯步数),不是耗时。理由与 omgkit-match 那边一样: 墙钟会抖(这个文件里就记着一次"判据在报噪声"),而"涨幅"这个形状看不见 按比例整体变慢的退化。三种形状三种判法:

形状 判据 现值
单个大环 工作量恒为 0 走快路径,一次 BFS 都不做
线性并苯 每原子工作量不上升 ≈ 15.5 次 BFS/原子、10.4 步回溯/原子
theta 图 / 苯稠大环 钉死绝对值 O(n²),见下

环搜索在"围长大 + 走一般路径"上是 O(n²),这一条还没修

Horton 算法要对每个顶点做一次覆盖整个分量的 BFS,而这类形状的围长就是 n 的量级,迭代加深必须一路加到那么深。实测 theta 图 122 → 1922 原子(16 倍), 工作量 34 032 → 8 677 880(255 倍 ≈ 16²)。

不是人造的极端形状:穴醚、环芳烃、带一条交联的双环肽都是 theta; "大环上稠合一个苯环"是同一档(56 → 806 原子,222 倍 ≈ 14.4²)。 换句话说,任何不是纯环的大环都落在这里 —— 纯环走快路径。

不过实测真实语料一次都够不着:large.smi 的 8830 个分子合计只做 242 637 次 BFS,最坏的一个 1 252 次(90 原子的双环缩肽);hard.smi 68 个 合计 2 896;bridged.smi 25 个合计 2 517。都不到 theta k=40 一个分子的零头 —— 这个形状在语料里是缺的,那条测试补的正是它。

教科书修法是抑制二度顶点(把每条二度链缩成一条带权边,在缩图上跑带权 Horton),要把 BFS 换成 Dijkstra、处理重边、再把环展开回去 —— 单独一块的活。 钉死绝对值的意义就在这里:修好的那天数会变小,判据当场红,逼着改的人 把新数写进来。

顺带查出一条跑不到的剪枝

sssr 的模块文档把"平衡剪枝"(只取两条最短路长度差 ≤ 1 的对)写成 "把大环从立方压回来的关键,不加的话 200 原子的大环要 26 ms"。

它一次都没执行过。 无权 BFS 里任意一条边的两端距离差 ≤ 1,这是 BFS 的基本 性质;限深只会让越界的点距离保持"未访问",而那种点上一行已经滤掉了。实测: 全语料 8830 个分子 + 五种合成形状,这一支一次都没进过;把它整个关掉,三个 计数器在每一档上逐位相同。那个 26 ms 是别的版本上量的 —— 现在纯大环走 开头那条快路径,压根到不了这里。

守卫留着,外加一条 debug_assert 把这个不变量写明:哪天最短路换成带权的 (做上面那个修法时就要换成 Dijkstra),不变量就没了,而那时它必须重新起作用。

真正在起作用的是首步剪枝:变异实测把它关掉,新判据当场红,而三条墙钟 判据全都退 0

另外注意 [profile.test] opt-level = 2,而 cargo run --example 走 dev profile(opt-level 0),两者差约 2.8 倍。跨这两者比较数字会得出错误结论。

观察增长曲线用:

cargo run --release --example bench_pipeline -- <语料> permol   # 分步 + 每原子
cargo run --release --example bench_kekulize -- <语料> permol   # 单看 kekulize

不依赖基准的自测性质

规范化排序有严格的数学性质,可以完全自证 —— 这是唯一一处不需要 oracle 的层:

  • 重排不变性:任意原子重排后,canonical SMILES 必须逐字节相同
  • 往返恒等:parse(write(parse(x))) ≡ parse(x)
  • 净化幂等:sanitize(sanitize(x)) ≡ sanitize(x)

--stage l3 的基准里带 can_renumbered 字段(把原子顺序反转后重新输出), 可直接检查第一条。顺带一提,语料里有一条 RDKit 自己都不满足这条性质: [Co@OH5]1(N)(O)(S)(P)CCC1 重编号前后是 [Co@OH2][Co@OH30]

但自测性质有一个共同的盲区:第二种写法出自自己

上面三条的"另一种写法"全部来自我方 —— 重排是自己排的,往返是自己写的。 自家写出器漏掉的东西,读回来一样漏,两边共谋着"收敛"。 crates/omgkit-io/tests/canonical_invariance.rs 里还有一条 different_spellings_converge_to_one_canonical_form,写法是手挑的五组, 只走得到写的人想得到的形态。

crates/omgkit-io/tests/differential_l3.rs 补的是这一档:第二种写法取自 RDKit 写出的规范串,判据是

canonical(解析(语料里的写法))  ==  canonical(解析(RDKit 的规范串))

外加三列直接比:能不能读、去氢后的原子数、键数。冒烟语料 149 条、大语料 8839 条 各跑一遍(后者 1.5 M 不入库,标 #[ignore])。

这份基准入库以来一直没有读取方,截成一行全套测试照样绿。接上读取方的第一次 运行就抓出 8 条不收敛:

条数 哪一侧 是什么
5 我方 配位键旁边的原子无条件留方括号。N->[Cu][NH3]->[Cu] 是同一个分子(氮都是 3 个氢),净化把前者的氢放进 num_implicit_hs、后者放进 num_explicit_hs,于是只有后者走到"能不能去框"那个判断,而那里有一条"碰到配位键直接留框"的兜底 —— 同一个分子两串。已修:氢数交给 implicit_hs_for_bare_form 那条唯一的规则算,它本来就把配位键的价贡献算对了(给体 0、受体 1)
2 参照 RDKit 的写出器不写自由基碳上的手性,于是 chiral-ring-open-cw-ccw 两条语料塌成同一串
1 参照 RDKit 自己的规范串不是自己的不动点:OI(=O)OO=[IH](O)O → 再读回去触发净化第 1 步的卤素修正 → [O-][IH+](O)O。我方逐字节复现了这个行为,两次都与 2025.09.2 一致

后 3 条钉在 NOT_CONVERGENT 里,双向:多一条红,少一条也红。

另有一列单独比:规范串里有没有立体标记。收敛判据对"两边一起把立体标记丢 干净"是瞎的 —— 语料里六条非四面体的用例正是如此,我方两次都写不出,两串相同, 收敛判据全绿。其中 [Pt@SP1](Cl)(Cl)(N)N[Pt@SP3](Cl)(Cl)(N)N两个不同 的分子(顺铂与反铂),我方的规范串都是 N[Pt](N)(Cl)Cl —— 写不出 @SP/@TB/@OH 的代价不是"少个记号",是两个分子塌成一串。这八条(六条我方 写不出 + 两条参照写不出)钉在 STEREO_MISMATCH,同样双向。

大语料上一条例外都没有:8831 个读得进来的分子,五列全零分歧, 带立体标记的分子 311/311。冒烟那 11 条例外全是刻意造的边角,真实语料里 一条都没出现。

另有两类差分测试原理上抓不到的性质,各有专门的测试:

性质 测试
MolBuilder 邻接索引与暴力扫描一致 omgkit-io/tests/adjacency_index.rs
整条管线线性于分子规模 omgkit-chem/tests/scaling.rs

收口的外部裁判:check_byproducts.py

Rust 侧那些用例靠手写的规范 SMILES,天花板有两个:数量,以及作者想得到的形状capped 那一档可以整个交给外部实现独立算 —— 断口全是单键、不涉及电荷、空价数 正好等于要补的氢数时,没有选择余地,只有"在断口切开、断头补氢"这一件事可做。

python3 harness/check_byproducts.py <templates.jsonl> --self-test

当前:25849 条比过,0 分歧。注入缺陷验过判据有牙齿:翻手性 4/4、删原子 2527/2527、多补一个氢 1452/1452,全部抓到。

翻手性只注得进 4 条,这个数本身是结论:两万五千条闭合副产物里带手性中心的 只有 4 个。所以"切点落在手性中心时要重定基"那条缺陷在这份语料上照不出来, 它是靠构造判据立住的(见 omgkit-match/tests/byproduct.rsa_stereocentre_at_the_cut_is_rebased)。判据覆盖不到的地方,要自己说出来。

还没有判据守着的几处

判据覆盖面要自己说出来,不然"全绿"读起来像"全都验过了"。当前明确没有判据的:

没守住的 为什么难写
form_bonds 的三级优先级(提键级 > 跨分量 > 成环) 要分辨它得有 ≥3 处空价,而那种形状上 borrow_hydrogens 会从同一个原子连摘两个氢,结果由摘氢而非优先级决定 —— 造出来的用例判别的不是它声称判别的东西
apply_charges 的元素优先级(卤素 > 氧硫 > 氮) 总量由预算定死,落错原子只改写法不改账;要判别得构造"两个候选元素不同且写法可区分"的片段
MAX_BONDS 这个上限本身 它是设计决定,不是可观察行为
build_fragmentstereo_atoms 要构造"参照原子落在片段之外"的双键

前两条是会被写进文档的设计决定,而没有判据的设计决定其实没有生效证据 —— 这一条记在这里,是因为下次改到那两处时不会有任何东西报警。

顺带记一个待查:上面第一格里说的"从同一个原子连摘两个氢",是探路时撞见的, 还没判定它是不是缺陷。

--min-checked 是防空过的闸:真正比过的条数低于它就直接失败。语料换了、口径改了 都可能让这一档悄悄变空,而那时"零分歧"依然成立 —— 最会骗人的一种绿。

写这条判据时自证先抓到了判据自己的毛病:注入"多补一个氢"那一档,先置 NO_IMPLICIT 再读总氢,读到的已经是 0,于是注入变成空操作,自证误报"判据抓不住" 34%。自证也要能自证。

配位几何(@SP/@TB/@OH)的读写:三张量出来的表

非四面体立体("配位几何")先前读得进来、写不出去。代价不是"少个记号" —— 语料里 [Pt@SP1](Cl)(Cl)(N)N[Pt@SP2](Cl)(Cl)(N)N 是两个几何异构体、图完全 相同,丢掉序号之后塌成一串。三类现在都写得出来了。

表是从参照实现穷举量出来的

序号的含义依赖"配体按什么顺序列出",这件事没法凭记忆写对。做法:取互不相同 的配体,穷举"每个序号 × 每种列出顺序"的全部写法,交给 RDKit 2025.09.2 规范化, 看哪些写法落到同一个分子:

类别 配体数 序号数 写法数 归成几组 转动群阶
@SP 4 3 3 × 24 = 72 3 8
@TB 5 20 20 × 120 = 2400 20 6
@OH 6 30 30 × 720 = 21600 30 24

组数恰好等于序号数、每组大小恰好等于顺序数 —— 序号与立体异构体一一对应。 转动群的阶也对得上几何:方形是 8(平面四方没有手性,镜像还是它自己)、 三角双锥 6、八面体 24。

模型:把"序号 1 在恒等顺序下"的那些位置定义成多面体的顶点。于是两样东西 都直接从测量里读出来,不用手写几何:转动群 R = 序号 1 的稳定子;每个序号的 顶点对应 p_i 满足 isomer(i, σ) = isomer(1, σ∘p_i)。模型在全部写法上验过 (72 / 2400 / 21600 条,反例 0),外加陪集判据 isomer(1,α) = isomer(1,β) ⟺ ∃r∈R: α = β∘r(576 / 14400 / 518400 对,反例 0, 其中真同构的分别是 192 / 720 / 17280 对 —— 不是空过)。

换算:要把序号 i 从顺序 from 换到 to,即求 j 使 to∘p_j = (from∘p_i)∘r。左乘 to⁻¹p_j = q∘r,q = to⁻¹∘from∘p_i。 那些 p_j 恰是各右陪集的代表元,所以 j 有且只有一个。

顺带一提,@SP 那张表有个人读得懂的等价说法:序号就是"按列出顺序哪两对配体 互为反位"(@SP1 是 (1,3)(2,4)、@SP2 (1,2)(3,4)、@SP3 (1,4)(2,3)), 与规范里 U / 4 / Z 三种形状对应。这条规则只写在判据里,实现走通用机制, 两条路互相对账(polyhedron.rs平面四方的表与反位配对规则对得上)。

序号"相对什么顺序"

三类都归一到邻居的存储顺序。方括号里的氢(或者一个空的配位位置)也占一个 顶点,而它不在键序列里 —— 少了它换算出来的是另一个排法。

那个顶点落在哪一位,同样是量出来的。 三类几何 × 三种上下文,每一档都穷举 "每个序号 × 每种列出顺序",看哪个插入位置能让分组与 RDKit 2025.09.2 吻合:

上下文 吻合的插入位置 别的位置
手性原子居首 [Pt@SP1](N)(O)S 第 0 位 全不合
前面有原子 N[Pt@SP1](O)S 第 1 位(紧跟前驱原子) 全不合
方括号里写了氢 [Pt@SP1H](N)(O)S 同上两条 全不合
中心上有环闭合 S1CCCO[Pt@SP1]1N 环闭合之前 之后不合

一句话:它落在"自身位置" —— 紧跟前驱原子之后、环闭合之前,与四面体里 隐式氢占的是同一个槽。空位与方括号里的氢行为完全一样(同一个分子骨架下 两者给出的序号映射逐条相同)。存储序里没有"前驱原子"可言,所以约定排在最前; 约定本身怎么定无所谓,两侧用同一份才是关键 —— 解析与写出都从 smiles::coordination_ligands 取配体序列。

缺两个及以上顶点仍然整个丢掉。 那时那几个顶点在 SMILES 里全落在同一处、 彼此分不开:实测 RDKit 把 [Pt@SP1](N)O[Pt@SP3](N)O 归成同一个分子 (3 个序号 × 2 种顺序归成 3 组,组内序号是 [1,3][1,3][2]), 这时"换算成另一个列出顺序下的序号"不唯一,写出去就是撒谎。配体数多于 顶点数时也丢:参照在这一档写的是不带序号的 [Pt@SP],而那种写法读回来是错的。 两档都钉在 roundtrip_smiles.rs::coordination_stereo_with_two_vertices_missing_is_dropped

我方比参照多收敛了一档

序号相对列出顺序,所以同一个分子在配体可互换时会有多个序号。实测:把 [Pt@SP1](Cl)(Cl)(N)N 重排原子再规范化,RDKit 会在 @SP1@SP3 两串 之间跳(@SP2 只有一串)—— 按它自己的行为这两个序号就是同一个分子, 只是它的规范化没把这个自同构收掉。我方收敛到一串。

环闭合落在中心上时更明显:[Pt@SPn]1(Cl)(N)CC1 的两个环碳等价,交换它们把 @SP1 的配对映成 @SP2 的,@SP3 的不动 —— 只有两个分子。我方两串, RDKit 三串。

(顺带纠正一句错话:那两个铂配合物不是顺铂与反铂,而是同一个异构体的两种 写法;顺/反之分在 @SP2@SP1/@SP3 之间。)

判据分三层,各守各的

判据 守什么 要 RDKit 吗
polyhedron.rs 的 5 条 表的结构:陪集代表元、转动群是个群、换算可逆且双射、@SP 与反位配对规则对账
roundtrip_smiles.rs 读写往返:11 族、每族 3/20/30 种排法各给出一串
canonical_invariance.rs 重排不变;缺一个顶点那 8 族的第二种写法出自参照 是(离线抄进源码)
harness/check_stereo_perm.py(CI 里的一道闸) 表与插入位置的标号:9 族 40224 种写法的分组与外部实现逐组比

分层不是形式:变异实测把 @OH 表的两行对调,本地那 5 条全绿(结构没变, 只是哪一行对应哪个序号变了),外部那条当场红。表的标号只有外部判据守得住 —— 这也正是那条判据存在的理由:表是量出来的,量完进了源码就没人再核对它。

三次变异实测:判据的洞与补法

变异 原本 补法
写出侧一律插在第 0 位(无视前驱原子) 全绿。忠实写出总从原子 0 起笔,而这批分子的中心恰好就是原子 0,"前面有原子"那条分支一次都没跑过;规范判据那边两侧一起错、正好抵消 往返判据加"中心不是首原子"的三族
两侧一起把那个顶点挪到环闭合之后 全绿,连外部闸也绿:幻影在全族里都紧挨着同一个环氧,一起对调等于给配体全局换个名字,分组不变 同一族里再放"三元环反着写"的两种形状 —— 环闭合那一端在 O 与 C 之间变,全局换名就不成立了
两侧一起无视前驱原子 外部闸 6 族红、本地参照判据红 已守住(幻影旁边是前驱原子,而哪个配体当前驱随列出顺序在变)

第二条尤其值得记:比"分组"的判据看不见"给配体全局换个名字"。要让它有区分力, 同一族里必须有几种写法,把被换的那两个配体的角色拆开。

其余校准

变异 结果
写出时不换算,直接写存储序上的序号 红(重排不变 + 方括号收敛);check_write.py 绿
解析时不归一,序号原样保管 红(解析侧的归一用例)
反位配对表第 2、3 行对调 红(4 条)
@OH 表两行对调 本地 5 条全绿,外部判据红

check_write.py 那格绿是因为语料里那几个分子的书写序恰好等于存储序, 漏换算在解析与写出两侧正好抵消。抓住它的是重排不变性(自己造顺序), 以及由参照给出第二种写法的那几对。

写出 molblock:判据必须走文件,不能走数组

.mol / .sdf 的写出接进来了(omgkit_io::molblock,V2000)。

先前那条判据把文件格式整个绕开了

verify_stereo.py 走的是 JSONL:原子表与坐标以数组交出去,判官在 Python 里 自己拼一个 RDKit 分子。于是计数行、原子块的价键字段、M CHG / M ISO / M RAD、键块的字段宽度 —— 一个都没有人读过。而别人拿到 .mol 文件时,读的 正是那些字段。

新判据 check_molblock.py 走文件:我们写,RDKit 读,读回来的分子必须满足输入 SMILES 指定的每一处立体(带手性的子结构匹配,与那条 JSONL 判据同一个口径)。

接进来的当天就抓到一条

芳香键落进了"其余按单键",噻吩写出去、读回来是四氢噻吩 —— 642 条里 448 条不符。而这个错在 JSONL 那条路上不可能出现:那边导的是键级数值, 根本不经过 molblock 的键级编码。

修法不是"给芳香键找个码"。molblock 里没有芳香键这回事,4 号键级各家读法不一; 写出器现在遇到芳香键直接报错,由调用方先凯库勒化。按单键悄悄写下去是更坏的 选择:它给出的是一个能读、能净化、看不出破绽,只是错了的文件。

修完:640 一致、0 不符、2 条判官够不着(与 verify_stereo.py 同一批三配位磷)。

一个格式化器,两个调用方

depict 那边的 dump_molblock(二维 + 楔形)先前自己写了一份格式化。两份必然 分家 —— 比如"窄端必须是键的第一个原子"这条规则,改一处漏一处就是把楔形 指到另一个原子上。现在两条路都调 omgkit_io::molblock::write_v2000,那条规则 只写在写出器里,调用方给的是"窄端在哪个原子"。

重构不改行为:check_wedge_readback.py 的数与重构前逐个相同(构型一致 495、 判官读不出 2)。

读取:另一个方向也要跑

写出侧与读取侧走的是完全不同的代码,一条绿说明不了另一条。所以补了对称的 一条:外部实现把语料里每条 SMILES 写成 V2000(二维),我们读那个文件、写回 SMILES;它也读同一批字节、写回 SMILES;两串都交给它规范化再比。

跨实现不能直接比规范串 —— 两套规范化算法给出的字符串本来就不一样, 所以统一由外部实现判"是不是同一个分子"。

实测 8831 条:8830 一致、0 不一致。剩下 1 条是二茂铁(铁上十根键),外部实现 自己就写成了 V3000,而我方明确认出并拒收 —— 那是一档有意的限制,单独计数 并配了上限 3,免得悄悄长大。

变异实测:漏读 M CHG(只用原子块那个旧电荷码)→ 303 条不一致 + 1099 条 读不了;漏读价键字段 → 7 条不一致。两条都当场红。

立体:楔形与顺反两档都接上了,判据随之收紧

楔形反读先前住在 omgkit-depict,而 omgkit-io 的读取器够不着(depict 依赖 io, 反过来不行)—— 于是我上一步在 io 里又造了一个楔形类型,一个概念两个类型。

搬下来了:omgkit_io::wedge。理由不是"图省事",是楔形本来就是文件格式里的 一个字段(molblock 键块第四列),读文件和画图共用同一套语义,住在 L1 才够得着。 depict 从这里 pub use,本 crate 的用法一字不改;实测导出的二维 molblock 与搬动之前逐字节相同

接上之后判据从"两侧都抹掉立体再比"收紧成三档,各配上限:

实测 上限
完全一致 8825
只差双键顺反 0 0
四面体也不同 5 5
读成了别的分子 0 0

四面体读到的中心数与外部实现一样多(310 对 310)。

顺反那一档也接上了(先前是 365 条,占了差异的绝大多数)。molblock 里没有 方向键,所以参照原子得自己挑:按存储序取这一端第一个合格邻居。挑哪个不改变 分子 —— 换一个参照只是换一套坐标系,顺反的值跟着一起变。什么算立体源(小环、 芳香、两端能否区分取代基)与 SMILES 那条路共用同一份判断,不再各写一遍。

上限 0 是两个方向都卡的:少读一根、多读一根、读反一根,三种都落进这一档。 它单看是个空断言,所以参照侧"有多少条真带顺反"也一起打出来并配下限 (实测 366,下限 300)—— 参照侧一条都没有时,零分歧说明不了任何事。

交叉双键不许照读。 键块第四列的 3 是"作者说不知道",而坐标照样画得出一个 确定的样子;照读就等于替作者把话说死。语料里外部实现写出 625 根这样的键。

变异标定(复制—改—还原—核 sha256,五处):

变异 语料判据 单元测试
顺反符号反过来 红(364)
不认交叉双键 红(549)
不查小环 红(9)
不查两端能否区分取代基 绿
参照不排除双键 绿 绿

第四行是真发现:写出侧的 informative_directions 会把那条噪声方向再滤一次, 所以语料判据够不着 —— 补了单元测试盖住它。

第五行盖不住,照实记。 丙二烯画出来是直线,那根双键的另一端正好落在轴上, 几何先一步判"读不出来";SMILES 那边 / 按语法也写不到双键上。这一条挡的是 两条路都到不了的输入 —— 留着(它写明了累积双键定的是轴手性),但不假装它验过。

还堵了一个不对称:只吃二维的函数拿到三维坐标,给的是错答案而不是空答案 (z 一投影照算不误,一根真正的反式双键完全可能投成同侧)。手性那侧没这问题 (三维图的楔形本就是空的,自然什么也标不出来),所以只在顺反这侧加:任何一个 z 不为零就整个不做。

那 5 条四面体差异全是桥环/稠环,而且两个方向都有:3 条我方少读(二维布局 退化时三个邻居张不出体积,按设计判"这张图定不出构型"),2 条我方多读(桥头碳的 构型由环系定死,外部实现的立体感知把这类标记摘掉,我方读的是纯几何)。 所以不是"我方保守",是立体感知的边界不一样

判据自己的一个洞,量出来才发现

头一版比法在 8831 条里报 26 条不一致,而手动一比是同一个分子。原因: 外部实现的 SMILES 解析器会保留"承载方向键的显式氢"([H]/[O+]=c1… 里那个 H 是一个原子),而我方眼下不读立体、写出的串没有方向键,同一个氢就被合并进 邻居 —— 两边原子数一个 9 一个 8。

那 26 条是比法造成的,不是读法。修法是抹立体之后再统一去一次显式氢。 记在这里是因为这类"判据自己的口径不对齐"最容易被当成实现的错去改。

还带上了三件先前会丢的东西

字段 不写会怎样
原子块的价键字段 vvv [CH] 读回来成 [CH3] —— 读的一方按默认价补氢
M RAD 自由基读成甲基
计数行超 999 时报错 挤出格的计数行,别人读进去是另一个分子

构型生成的 Python 绑定:判据是"与 Rust 侧逐位相同"

绑定那一层的规矩写在 omgkit-py 的模块文档里:只做翻译,不做化学。 理由是"一旦在这里写了判断分子的逻辑,它就只有 Python 用户能碰到,Rust 侧的 全套差分测试一概盖不到"。

构型生成正好踩在这条线上:它要先净化 → 感知双键顺反 → 补显式氢 → 抽手性中心 才能跑,而这个五步配方先前在 examples/feasibility.rsdump_conformers.rsbench_conformers.rstests/small_molecule_geometry.rs 里各抄了一份。 绑定再抄第五份的话,那一份就是整个项目里唯一没有判据覆盖的一块。

所以先把配方收进库里(pipeline::conformer_for / pipeline::prepare), 四个调用方改成调它,绑定只调同一个函数。

判据不另造真值

同一条 SMILES,examples/dump_conformers 走 Rust、Mol.conformer() 走 Python, 原子序数、形式电荷、键表、坐标逐位相同。两侧调的是同一个函数、全程无随机数, 所以"逐位相同"是可以要求的 —— 差一位就说明翻译丢了东西。

实测 642 个分子(large.smi 里带立体标记的那些),0 条不同。

顺带把 verify_stereo.py 那条外部判据继承过来了:Rust 那侧的坐标已经交给 RDKit 从三维读回过立体化学(640/640),Python 这侧与它逐位相同,自然同样成立。

变异实测:让绑定返回补显式氢之前那份分子(一个很自然的错 —— 调用方 手里那个分子确实没变),642 条里 641 条红(原子数对不上)。

分母闸

真正比过的条数低于 500 就直接失败。语料换了、上游筛选变了都可能让这一档悄悄 变空,而"零分歧"在那时依然成立。

写出侧:SDF 多记录 + 二维 molblock

SDF 写出,顺带把一条老判据补严实了

write_sdf_record(mol, &rec, &[(名字, 值)]) = molblock + 数据字段 + $$$$

装不下的字段报错,不悄悄改写。 SDF 的记录边界靠行认:字段之间隔一个空行, 记录之间隔一行 $$$$。所以值里不能有空行、不能有单独一行 $$$$,名字里不能有 < > 或换行 —— 有的话读的人会把这条记录切在别处,而写的时候一点毛病都 看不出来。清洗那种值是调用方的事:该截断、该转义、还是该报错,只有它知道。

接上之后 dump_molblock3d 导的是真 SDF(行号与原串是两个数据字段), 先前它每条前面加一行 >>> 行号\t原串、后面手写 $$$$ —— 于是 $$$$ 摆在哪、 数据字段怎么写,外部实现一次都没读过,而那正是 .sdf 交出去之后别人要读的 东西。判官改用 ForwardSDMolSupplier 当普通 SDF 读,并且比条数(它切出来的 记录数必须与文件里的一致)。实测 640/640 一致、数据字段一条没丢。

变异实测:字段后面不留空行 —— 外部实现只读出 321 条(文件里 642 条); 记录不写 $$$$ —— 读出 1 条。两处单元测试也一起红。

二维 molblock 写出接进 Python

Mol.to_molblock_2d(title="")。绑定这一层为此依赖了 omgkit-depict (default-features = false,关掉位图输出那一串外部依赖)—— 二维坐标与楔形是 画图算出来的,写出器在 io 那边,两样必须凑齐。

判据与 Rust 侧逐字节比(dump_molblock 那条)。Rust 那侧已经由 check_wedge_readback.py 与外部实现比过(拿 RDKit 从二维 + 楔形指派手性, 逐个中心比 CIP 码),这一侧与它逐字节相同,那条外部判据就继承了过来。 实测 311 条逐字节相同、0 条不一致。

"逐字节相同"在两边都不画楔形时同样成立,所以另配了"带楔形的条数"下限 (实测 311,下限 280)。

变异实测:

变异 结果
写原分子而不是"画出来的那个" 红,86 条
不传楔形 红,311 条
漏掉顺反感知 红,13 条

第一行是那条链上最容易接错的一步:为画出构型补的显式 C–H 也在"画出来的那个 分子"里,而楔形恰恰打在那根键上。拿原分子写,那根键根本不存在。

三维文件的立体:从坐标反读

读三维 .mol/.sdf 时,手性靠有符号体积、顺反靠二面角。与二维那条路 (楔形定手性、平面投影定顺反)没有一行代码是共用的,一条绿说明不了另一条。

四个 assign_*(二维/三维 × 手性/顺反)各自认维数,不合的那一对返回 0。 调用方四个全调 —— 把 if is_3d 写在调用方,"什么时候读得出立体"就有了两个住处。

号的约定不另立一套

仓库里已经有两个"有符号体积",号相反,模块文档专门写了一段防止踩混。三维读取 用中心基点那个(det[l₀−c, l₁−c, l₂−c],正 = @),与 omgkit_conf::chiral 同一个量 —— 四配体那个行列式完全不看中心原子在哪,伞形翻转时它一点不变而 真实构型已经翻了。外部实现的三维感知也用中心基点。

跨 crate 钉住了:新加的测试走完整条路(生成构型 → 清掉标记 → 从坐标读回来 → 与原标记比),把号翻过来当场红。没有它,"自己生成的构型交给自己读回来变成 对映体"这种事两边各自都自洽,谁也不报错。

三个坑,都是三维语料才逼出来的

一、方向键写在显式氢上。 外部实现保留氢时读我方的串是对的,默认(解析时 去显式氢)读则翻掉一根双键 —— 立体被挂在了一个"别人随手会删掉"的原子上。 修法:规范化挑参照时先挑非氢邻居。二维/隐式氢那条路上根本没有氢原子, 这一改不动它分毫。

二、同一个双键端上两根同号的方向键。 共轭链里中间那根单键的方向由相邻双键 定,而这一端又给兄弟键也写了一个,两个各算各的、同号了没人拦 —— 那在几何上 不可能,外部实现读到自相矛盾只能把整根双键的立体丢掉。修法:把"同一端上的 两根方向键必须反号"补进约束图。按双键的端点收,不按参照键的锚点收 —— 共用单键锚在前一根双键那侧,冲突却在后一根双键的端点上。

两条都补了单元测试(断言的是方向指派本身没有同端同号、参照不落在氢上; 拿"自己读回自己"去测是测不出来的,我方读自己那串一点毛病没有)。

三、"是不是真手性中心"的判断被噪声污染。 三维坐标给每个 sp3 原子都算得出 一个号,甲基、亚甲基一个不落;那批噪声让 genuine_tetrahedral 把叔丁基认成 真中心。改成从上往下削:先全当候选,再反复剔掉"有一对等价邻居、而支路里 没有别的候选"的。从下往上补行不通 —— 1,4-二取代环己烷那种两个中心互相依赖 的谁也当不了种子,实测那样分歧从 17 涨到 47。

判据自己又栽了一次:输入生成半路断了,而我在后面接了 tail

EmbedMolecule 嵌不出来有两种表现:返回 −1,以及直接抛 (Invariant Violation: bad lower bound,界矩阵自相矛盾时)。头一版只接住了前者, 于是 --write 半路 panic,写出去的文件是截断的 —— 全量只写了 7950 条就停了。

而我当时在那条命令后面接了 tail -1,一点没看见,还拿那份截断的文件量了好几轮。 这正是 harness/gates.sh 开头记着的那条,我在临时命令里又犯了一次。

修完之后写全 8795 条,并给"真正比过的条数"配了下限:嵌入那一步再断、或者换个 版本嵌得更少,判据都会在一份悄悄变短的文件上报"逐条一致"。

一把尺的口径

"张不出体积就不判"这条,三维量的是未归一化的体积(ų),与外部实现同一把尺。 语料里正好有一个亚砜嵌得几乎共面:归一化 −0.061(会被拒)、原始 0.268(该读)。

结果

实测
逐条一致 8779
读成了别的分子 0
骨架对、只有立体不同 16(上限 16)

(语料 8831 条,外部实现自己嵌不出三维构象的 36 条。)

那 16 条逐条查过,只有两类,都是能力/感知边界,不是读错:12 条是外部实现 读了非四面体立体(@TB/@OH/@SP,六氨合钴那一族),我方只做四面体; 4 条是三价磷 —— 我方认它是立体中心,外部实现的三维感知不认。

变异标定:

变异 结果
手性的号翻过来 红(跨 crate 那条测试)
三维不认交叉双键 红,555 条
兄弟方向键不反号 红,18 条
参照允许挑到氢 红(单元测试)

第二行顺带答了一个问题:外部实现读三维 molblock 时同样认键块第四列的交叉 双键,所以这道拦截在三维上是承重的,不是照搬二维的习惯。

SDF:一次读一批

omgkit_io::molblock::read_sdf(text) 逐条给一个 Result。先前只能一次读一条, 多条的文件要调用方自己按 $$$$ 切 —— 而那正是容易出错的一步。

一条坏记录:两种常见做法都不行

整份拒收会让一条坏记录废掉上万条好的;静默跳过会让分母悄悄变小 —— 调用方数出来的条数与文件里的不符,而没有任何地方报错。所以每条给一个 Result, 坏的那条以 Err 出现在它自己的位置上,后面的照读不误。

语料里天然有这么一条:二茂铁的键数超出 V2000 的表达能力,写出方自己就换成了 V3000,而 V3000 我方明确拒收。它落在文件中间,后面几千条照读不误才算数。

M END 是分子的终止符,也是数据字段的起点

没有它就不知道分子在哪结束、数据从哪开始,所以缺了就报截断,不猜 —— 猜的话, 一条被截断的记录会被当成"分子读完了、只是没有数据"。

最后一条可以没有 $$$$(有些写出方不写),但照样要有 M END

判据:分子、数据字段、条数,三样一起比

实测
逐条一致(分子 数据字段) 8825
不一致 0
骨架相同、只有立体不同 5(上限借自 check_molblock_read.py)
我方拒收的 V3000 1

立体那一档的上限不在这里另立。这条判据管的是切分,立体感知的边界是另一件事, 已经由 check_molblock_read.py 在同一份语料上守着 —— 两处各写一个上限的话, 那边收紧了这边不会跟着紧,而"这边松着"没有任何理由,只是没人想起来改。

判官写出的那份文件里,每条都带三种字段:一行的、多行的、以及某一行以 > 开头的。最后一种是切分的经典陷阱(字段头也以 > 开头)。头一版没踩到 —— 那个 > 被我写在了行中间,而危险的是行首。改过来之后陷阱才真的在数据里。

变异标定:

变异 结果
> 就开新字段 红,8830 条
字段名取整行,不取尖括号里那段 红,8825 条
读不了的静默跳过 红,条数对不上
$$$$ 那行不去行尾空白 语料判据绿,单元测试红

最后一行是语料碰不到的:判官那份文件是外部实现写的,$$$$ 干干净净。真实文件 里拖空格的有,所以留着 —— 而"留着"要有个东西验,补了单元测试。

判据自己先错了一次:传输把值改掉了

头一版把数据字段编成 名字=值、换行写成 \n。判据当场报两条"数据字段不同", 而读取器一点毛病没有:语料里有条 SMILES 是 [H]/N=c/1\nc[nH]s1,里面那两个 字符本来就是反斜杠加 n,解码时被当成了换行。

改成 JSON。自己拼的转义没有转义反斜杠本身,是个必然会撞上的洞 —— 而它表现为 "实现有 bug",查错的方向一开始就是反的。

SDF 也接进了 Python 绑定

omgkit.read_sdf(text) -> list[SdfRecord]。记录类上有 .block(读不了时是 None)、 .data.error

Result 在 Python 里没有对应物,而两种"自然"的翻法都是刚在 Rust 侧明确否掉的: 抛异常会停在坏记录上、后面几千条一起丢;静默跳过会让条数变小,而调用方数出来的 与文件里的不符,没有任何地方报错。所以每条都在列表里占一个位置,坏的那条把原因 放在 error 里。

判据与 Rust 侧逐条比,失败落在第几条也要一样 —— 只比集合的话,整个文件错位 一条时每条都还是"读得出",只是配错了对象。

实测
逐条相同 8830
两侧都读不了 1(下限 1)
不一致 0

"两侧都读不了"配了下限,不是上限:一条都没有的话,"坏记录留在原位"这一档 就没人验过,而那正是这条判据存在的理由。

变异标定:

变异 结果
读不了的静默跳过 红,条数当场对不上
数据字段丢掉 红,8830 条
共用的那一步不打立体标记 红,641 条

最后一行同时说明:净化 + 打立体标记那两步是单条与整份共用一段代码的 (finish),不是各写一遍。

.mol 文件接进 Python 绑定

omgkit.parse_molblock(text) -> Molblock,类上有 .mol / .coords / .title / .is_3d。写出那一半先前就通了(Conformer.to_molblock),读进来这一半补上。

它替调用方净化了,而且必须

别的 parse_* 交回来的是没净化的分子,什么时候净化由调用方定。这条不行:

  • SMILES 的立体写在串里,推迟净化不丢任何东西;
  • molblock 的立体一半在坐标与楔形里,而那两样在 Mol 之外。给它们打上标记 要先知道每个原子有几个隐式氢、要用对称等价类 —— 两样都是净化算出来的。

顺序只能是"读 → 净化 → 回来打立体标记",而中间那一步一旦交给调用方,漏了不会 报错,只会静默地把整个文件的立体丢掉:分子照样合法、原子数照样对,只是 @/ 全没了。这与 Mol.sanitize 把顺反感知并进来是同一个理由 —— 绑定层是给人 直接用的,把必须成对的两步拆开就是个陷阱。

判据:与 Rust 侧逐字符相同,外加两条不变式

同一批 molblock,examples/read_molblock 走 Rust、parse_molblock 走 Python, 写回来的规范 SMILES 逐字符相同。Rust 那侧已经与外部实现比过 (check_molblock_read.py),这一侧与它相同,那条外部判据就继承了过来。

实测:逐字符相同 8830 条,两侧都拒收 1 条(二茂铁,外部实现自己写成了 V3000), 不一致 0 条。

"逐字符相同"在两侧一起把立体丢光时同样成立 —— 那正是这条判据要抓的故障, 而它会以全绿的样子出现。所以另配一条"带立体符号的条数"下限(实测 641,下限 600)。

第二条不变式:坐标条数必须等于净化之后的原子数。净化那一步万一动了原子表, 坐标就与分子错位了 —— 条数照样对、分子照样合法,只有几何整个搬了家。

变异实测(改绑定、重建 wheel、还原、核 sha256):

变异 结果
不打手性标记 红,310 条
不打顺反标记 红,366 条
不净化 红,6807 条
净化之后并掉显式氢(冒充"净化动了原子表") 红,26 条

最后一行只在语料里那 26 个带显式氢的分子上触发得到 —— 那一条是不变式,在 8831 条上都查,只是其余的分子上它平凡成立。

顺带堵上手性那一侧的三维漏洞

assign_chirality_2d 先前拿到三维坐标会把 z 一丢、按 xy 投影算体积,算得出 一个与分子无关的答案。空答案可以接受,错答案不行 —— 现在与顺反那一侧同一条线: 任何一个 z 不为零就整个不做。配了一对对照测试:同一张画着楔形的图,平的读出 1 个中心,抬起一个 z 就读出 0 个。

头号指标的参照:量得出来才算数,而且它跟 RDKit 版本走

omgkit-conf/examples/feasibility.rs 里那条硬闸的理由是"要赢 RDKit ETKDG 的 失败率",而那个参照数原先只是一句引用:0.52%,出处是 harness/baseline_rdkit_etkdg.py

问题是那个脚本一行都跑不了 —— 它的语料路径写死成一个早已删掉的 worktree 的绝对路径(.claude/worktrees/agent-…/harness/corpus/large.smi)。也就是说, 这个项目的头号参照从某一刻起就没法复核了。requirements.lock 开头正记着同一个 病的另一种长相:"靶子立在三年前"。

改法:语料改成命令行参数、默认取仓库里的 harness/corpus/large.smi,并且把 RDKit 版本打在最前面 —— ETKDG 每个版本都在改,这个数不带版本就没有意义。

重量一遍,两侧同口径

两侧的计时都只包住生成本身:RDKit 那边 MolFromSmilesAddHs 在计时 之外,我方这边解析、净化、感知顺反、补显式氢也在计时之外。同一台机器、 同一份 large.smi(8831 个能建界的分子)、单线程。

python3 harness/baseline_rdkit_etkdg.py harness/corpus/large.smi
cargo run -q -p omgkit-conf --release --example bench_conformers -- harness/corpus/large.smi

2026-08-26 实测:

RDKit ETKDGv3 2025.09.2 omgkit
拿不到坐标 36(0.41%) 1(0.01%)
平均 6.1 ms 1.1 ms
中位 2.3 ms 0.4 ms
p99 53 ms 16 ms
最慢的一个 5.23 s 0.11 s
墙钟合计 54.2 s 10.4 s

(我方这一列是加上确定性重试阶梯之后的数;阶梯之前是 0.7 ms / 7 ms / 0.05 s / 6.6 s,但那时甲醇的 C–O 键是错的 —— 见下一节。)

参照自己变好了:0.52% → 0.41%(旧数出自更早的 RDKit),平均耗时 9.8 → 6.1 ms。 所以仓库里引用它的地方都改成了带版本的 0.41%。

难例语料(hard.smi,68 个)上参照已经追平:

RDKit ETKDGv3 2025.09.2 omgkit
拿不到坐标 0(0.00%) 0(0.00%)
平均 3.6 ms 0.5 ms
最慢的一个 0.05 s 0.01 s

docs/dev/building.md 先前记着"同一批分子 RDKit ETKDGv3 失败 2 个:SF₆ 与 六氨合钴" —— 那是更早版本上的事,2025.09.2 两个都能嵌出来。没复核就别转述: 我差点照着那句话把 2.94% 写进判据的输出里。

这个比值要连口径一起引:两边跑的不是同一个算法(ETKDGv3 的距离几何带它 自己那套扭转/手性项;这边是"建界 → 三角光滑 → 嵌入 → 破对称 → 全局定向 → 精修")。比的是"从一个分子拿到一组三维坐标要多久",不是同一算法的实现快慢。

顺手抓到的第二件事:棘轮松掉了

那条闸的注释写着"闸设在 0.12%(约 10 个分子),贴着现值 5 个分子的棘轮, 留了一倍余量"。而现值后来降到 1 个分子(0.01%),注释没跟着改 —— 于是那道"一倍余量"的棘轮变成了十倍余量,一个 10 倍的退化能悄悄溜过去。

现在设在 0.04%(3 个分子),仍是现值的 3 倍。留 3 倍而不是 2 倍,是给跨架构 浮点差与将来往语料里加分子的余地;这个数本身是确定性的(同一份语料跑两遍 逐位相同)。变异实测:把闸收到 0.005%,当场红退 1。

棘轮要跟着现值走。 指标改善之后不动闸,闸就自己松了 —— 而这种松是静默的。

一个常量管两份语料,就只能跟最差的那份一样紧

feasibility.rs 的四档几何越界(1-2 键 / 1-3 角 / 1-4 扭转 / 长程)先前是: 1-2 与 1-3 各一个全局常量,1-4 与长程根本没有闸 —— 而报告里最大的那个 越界率恰恰是 1-4(hard.smi 上 3.335%,是 1-3 的四倍)。只打印不设闸的数, 读起来像"守住了"。

全局常量还有第二个毛病:同一个数要同时管 large.smihard.smi,于是它 只能跟更差的那份一样紧,在另一份上就是瞎的 —— 1-3 的闸 1.5% 对 hard 的 0.818% 是 1.8 倍,对 large 的 0.148% 却是 10 倍。

变异实测:把精修迭代从 400 砍到 60

1-2 键 1-3 角 1-4 扭转 长程
large 现值 0.026% 0.148% 0.353% 0.257%
large 变异后 0.155% 1.005% 0.956% 1.444%
旧闸会不会红 勉强(1.03 倍) 不红 没有闸 没有闸
hard 变异后 0.074% 1.186% 5.196% 0.215%
旧闸会不会红 不红 不红 没有闸 没有闸

也就是说:一个能让全语料几何明显变差的缺陷,旧闸只在一档上勉强红一次, hard.smi 那侧一条都不红。

改成逐语料棘轮(每份语料记下自己的现值,闸取 2 倍;对数少的档另配一个 绝对下限,免得量化噪声抖红)之后,同一个变异:large 四档全红, hard 长程那档红。

语料不在名单里时回落到一组粗闸,并且打印一行说它没被钉住 —— 静默回落等于给新语料免检。

它顺手照出来的:smoke.smi 从没被喂给这条闸

拿那份粗闸跑一遍 harness/corpus/smoke.smi(解析冒烟语料,137 个建得出界的 分子),当场红:1-2 键越界 0.932%、1-3 角 2.021%、界不可行 2.19% (两个配位键铜配合物 [Cu]1<-NCCN->1[Cu]<-1CCCC1,外加已知的钍配合物)。 largehard 上分别是 0.026% / 0.074%。

那份语料里都是小分子与解析边角料,而被闸盯着的两份全是药物样大分子 —— 判据的输入分布把一整档排除在外,是这个仓库反复踩的同一个坑。

追下去:甲醇的 C–O 键短了 0.16 Å —— 精修落进了局部极小

逐个分子量下来:

分子 原子数 最坏键长偏差 精修
CO(甲醇) 6 +0.163 C–O 实测 1.211,界 [1.374, 1.394] 10 步,误差 → 4.04e-2
CS(甲硫醇) 6 +0.258 C–S 实测 1.514,界 [1.772, 1.792] 11 步,误差 → 8.53e-2
CN(甲胺) 7 +0.000 25 步,误差 → 0
CF / C=O / CC / CCO / CCCO / OO 4–12 +0.000 收到 1e-13 或 0
CO.CO(两份甲醇当两个片段) 12 +0.000 265 步,误差 → 1.4e-9

最后一行是关键:同一个分子,凑成两个片段就正常了 —— 不是化学的问题。 界也没问题:手算能摆出满足全部 15 对的构型。

先前我把它诊断成"线搜索步长缩到底",那是错的(那句话进了 c8ed593 的 提交信息)。把 converged 露出来之后看清楚:甲醇收敛了,梯度 4.4e-7, 从产物原地再起一次走 0 步 —— 它停在一个真的局部极小上。

换参考距离表也没用:在上下界之间取六种插值,全落到同一个极小、三位有效数字 一致。原因是键的界宽只有 0.02 Å,插值改不动形状 —— 那六个"不同起点"其实 几乎是同一个点。而把起点扰动一下,12 次里 11 次收到 ~0。

修法:确定性重试阶梯

残差没到 ~0 就换个扰动过的起点再跑,最多 4 级,取最好的一次。扰动用整数 哈希而不是 sin —— libm 的最后几位在不同平台上未必一样,而这个项目要的是 "同一个分子在哪儿跑都给同一组坐标",全程无随机数。

取"最好"时按三把尺子,顺序不能换:手性对了几个 → 断了几根键 → 残差。 第二把尺子是量出来才加的:只按(手性, 残差)挑的话,hard.smi 上那个卟啉分子的 越界键从 1 根变成 4 根 —— 阶梯挑了个"总残差更小"的解,而它把误差摊到了 更多键上。总残差小不等于化学上更对。

效果(全语料,出厂坐标):

改前 改后
large 1-2 键越界 66 对(0.026%) 19 对(0.007%)
large 长程越界 8127 对(0.257%) 2148 对(0.068%)
large 至少断一根键的分子 29 13
large 精修没收敛的分子 618(7.00%) 443(5.02%)
hard 1-2 键越界 1 对(0.074%) 0 对(0.000%)
小分子(甲醇/甲硫醇/叠氮甲烷/高氯酸/高溴酸) 12 处越界 0

代价:平均每分子 0.7 → 1.1 ms(参照 6.1 ms),p99 7 → 16 ms(参照 53 ms), 最慢 0.05 → 0.11 s(参照 5.23 s)。仍比参照快 5.5 倍,失败率不变(0.01%)。

四档的棘轮在同一次提交里跟着收紧了(指标变好而不动闸,闸就自己松了)。 小分子那一档钉在 crates/omgkit-conf/tests/small_molecule_geometry.rs: 关掉阶梯,它当场报 12 处越界。

丙二烯型轴手性(@AL):裁判换了一家,因为 RDKit 在这一档上没有能力

先前这一档读得进来、写不出去,而且更糟 —— 把 @/@@ 写在丙二烯中心上的 那种等价写法,我方写出的是另一个分子:拿中心那两根键去算四面体宇称, 换一端起笔就翻。实测 NC(Br)=[C@]=C(O)F 写成 [C@@](=C(O)F)=C(N)Br, 外部实现判定那是另一个异构体。

四个配体来自两端,不是中心自己的邻居

丙二烯的立体信息属于一根:中心只有两个邻居,四个配体是两端端原子上的 取代基。顺序是"两端按中心的键序排,端内按该端自己的键序排",端上的氢落在 "自身位置"(紧跟前驱原子之后、环闭合之前)—— 与四面体的隐式氢、配位几何 缺的那个顶点是同一个槽。

这条推论有个有用的副产品:两端谁先谁后无所谓。对调两端是 (a,b,c,d) → (c,d,a,b),两次对换、偶置换,分子没变。变异实测确认全绿 —— 那不是判据的洞,是它本来就不该红。真正要紧的只有两件:哪两个配体在同一端、 每端内部谁在前。

@AL1@@AL2@@(实测),所以两种写法归到同一个表示: ChiralTag::Allene + stereo_perm 1/2,相对四个配体的存储序。写出一律写 明确形式 @AL1/@AL2 —— @ 写在两配位原子上太容易被读成四面体。

裁判是 Indigo,不是 RDKit

仓库钉的 RDKit 2025.09.2 完全不支持丙二烯立体。逐条实测:

路径 结果
MolFromSmilesMolToSmiles @AL1@AL2 给出同一串
FindPotentialStereo(新立体感知) 只报两根双键,不报轴
rdCIPLabeler.AssignCIPLabels 一个标号都没有
EmbedMoleculeAssignStereochemistryFrom3D
MolToV3KMolBlock → 回读
HasSubstructMatch(useChirality=True) 两个异构体互相匹配

连方向键写法 F/C=C=C=C/F 也丢。这一档它当不了裁判,而"没有外部裁判就 自己跟自己比"正是这个仓库一直在防的假绿。

所以 harness/requirements.lock 里多钉了一个 Indigo 1.46.0,只服务 harness/check_allene.py 这一条判据,不参与任何基准的导出。它同样要钉版本: 这条判据的一侧是本实现,版本一换就没得对消(与 check_smarts_chirality.py 同理)。

Indigo 自洽性先量过才敢用:exactMatch(…, "ALL") 认这个区别;@AL1@; 从另一端起笔序号不变;读→写链在两种写法之间摆动,但每一步都还是同一个 分子。它的规范串倒是把立体丢了,所以判据比的是 exactMatch 给出的分组, 不是字符串。

模型是穷举量出来的

同一个丙二烯 C(N)(Br)=C=C(O)F 的 32 种写法(先写哪一端 × 每端内部谁在前 × 中心是不是写在最前 × 两个序号),交给 Indigo 分组:2 组 × 16 条。 宇称模型逐条重现这个分组,反例 0。

四次变异实测:两次先前是绿的

变异 本地 外部闸 补法
端上的氢排到该端末尾(而不是"自身位置") 全绿 本地那族里加"氢端从中心那边写"的写法 —— 只放一种形状时参照那侧的氢位置与我方相同,两边一起错
写出侧不换算,直接写存储序的序号 已守住
端原子内部用存储序而不是书写序 已守住
对调两端 全绿 全绿 不用补 —— 偶置换,分子本来就没变

第一条与配位几何那一块量到的是同一个病:比"分组"或"是否相等"的判据,看不见 两侧一起犯的同一个错。要有区分力,同一族里必须放几种把配体角色拆开的写法。

还没做的

更长的累积双键(NC(Br)=C=[C@AL1]=C=C(O)F,四根双键)不支持 —— Indigo 也读不了, 这一档没有裁判,所以不做。我方把它当作"四个配体取不到",整个丢掉标记, 钉在 roundtrip_smiles.rs::allene_stereo_off_the_axis_is_dropped

扩到 ChEMBL## 扩到 ChEMBL

冒烟语料只够抓低级错误,真正的门禁需要全量:

curl -O https://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/latest/chembl_35_chemreps.txt.gz
gunzip -c chembl_35_chemreps.txt.gz | tail -n +2 | cut -f2 \
    > harness/corpus/chembl.smi

各层的门禁标准见 ../omgkit-phase1-scope.md §3。

写出侧的立体守恒:check_ez.py

往返测试(解析 → 写出 → 再解析)只能保证本实现自己读得回来。方向键的 参照系换算若整体错了一个符号,自己读自己仍然自洽,顺反却全反了 —— 拓扑完全 正确,只有分子是镜像的,肉眼极难发现。所以这一档的判官必须是外部实现。

cargo run --release -p omgkit-io --example dump_written -- \
    harness/corpus/large.smi > /tmp/written.tsv
python3 harness/check_ez.py /tmp/written.tsv harness/corpus/large.smi

两种失败分开计数,别合并成一个"分歧数":

后果
丢了立体 下游拿到"未指定",不会当成确定的顺反
写错或多写 下游会当真 —— 严重得多

当前:8831 条守恒,丢失 0,写错 0。

净化之后写出:check_write_fidelity.py

check_ez.py 守的是"不净化时写出不丢立体";这一档守的是"净化之后写出仍然 忠实"。两者必须分开跑,因为净化会重排氢的存放位置 —— 第 12 步把一部分 隐式氢挪进 num_explicit_hs,同时清掉 NO_IMPLICIT 标志。

cargo run --release -p omgkit-chem --example dump_sanitized -- \
    harness/corpus/large.smi > /tmp/san.tsv
python3 harness/check_write_fidelity.py /tmp/san.tsv harness/corpus/large.smi

判据是 规范(外部实现读原文)规范(外部实现读本实现写出的串) 相同。 两边都由外部实现读、外部实现规范化,所以比的只是写出这一步忠不忠实 —— 本实现的净化与外部实现有出入也混不进来,那由 L2 的差分单独守。

这条路径同时躲开两道已有的判据,所以必须单列一档:

已有的判据 为什么盖不住
不净化的往返测试 那时 NO_IMPLICIT 还在,方括号照写
L2 分步/全管线差分 比的是分子对象的字段,不比写出的字符串

写出器若按 NO_IMPLICIT 决定要不要写方括号,吡咯型氮的 [nH] 会写成裸 n, 氢凭空消失 —— 8839 条语料里有 633 条(7.2%)落在这个形状上。反应产物 尤其受害,产物必然是净化过的。

判据不能只比分子式

方向键写反、手性写反都不改分子式,却是实打实的另一个分子。变异实测:

注入的缺陷 规范串判据 只比分子式
[nH]n 491 抓得住
翻一条方向键 366 0
翻手性符号 300 0

翻方向键时要注意:成对翻转等于没翻(F/C=C/FF\C=C\F 仍是反式), 所以变异只能翻其中一条。翻全部会得出"判官没牙齿"的错误结论 —— 那时错的是 变异,不是判据。

当前:8831 条忠实,0 条出错。

双键顺反的感知:check_bond_stereo.py

cargo run --release -p omgkit-io --example dump_bond_stereo -- \
    harness/corpus/large.smi > /tmp/bs.tsv
python3 harness/check_bond_stereo.py /tmp/bs.tsv harness/corpus/large.smi

(第二个参数是同一份语料,判官拿它核分母 —— 见 harness/denominator.py。)

判据是三样一起比:哪根双键、什么顺反、相对谁。只比顺反值不够 —— 顺反离开参照原子没有意义,四取代双键上参照挑得不同,同一个几何会得出相反的值。 判官因此先把参照归一(两端各取下标最小的取代基,换一次参照就把顺反翻一次), 归一之后才比。

比的是由方向键直接决定的顺反,不是按 CIP 优先级定出的 E/Z —— 后者要先算 优先级,是另一件事,不在这一档的范围内。

判官那一侧的参照原本太宽:小环双键

接进 CI 的那一刻这条判据就是红的,大语料 8839 个分子里红一条:

CN1CCC\2=C1/C(=N\O)/S/C2=N\c3ccc(cc3)F     4=5 那根双键在一个五元环里

判官的参照那一侧用的是 SetBondStereoFromDirections —— 那是低层原语, 把 / \ 机械地折算成 CIS/TRANS,不问这根双键有没有资格带顺反。 小环里的双键被环锁死,没有 E/Z 可言(反式环辛烯是最小的能分离出来的 反式环烯烃,所以门槛是最小环 < 8)。RDKit 的高层解析问这件事, 本实现跟高层走。

改法:参照那一侧拿 RDKit 的高层解析(removeHs=False,下标与低层那份逐个 对齐)过一道筛,筛完之后照旧三样逐根比。这不是把判据改成"跟我们一样" —— 变异实测:把小环规则关掉,同一条判据报"本实现多标 1"并退 1。 筛掉的键全语料只有这一根。

当前:8833 条一致(其中 366 条确实有标注),0 分歧,6 条外部实现读不了原串。

变异验证过判官有牙齿:翻转顺反抓 153 条、抹掉标注抓 366 条、参照原子置零抓 366 条、关掉小环规则抓 1 条。第一项只有 153 是对的 —— 原本判 TRANS 的 那 213 条被翻成 TRANS 后不变。

规范 SMILES 的忠实性

canonical_smilessmiles::write两条不同的路径:前者会打破对称、 枚举取最小,还会调 genuine_tetrahedral 抹掉不携带信息的立体标记。 抹错就把两个分子塌成一个,所以它要自己的判据,不能靠 dump_sanitized 那档代劳。

cargo run --release -p omgkit-chem --example dump_canonical -- \
    harness/corpus/large.smi > /tmp/canon.tsv
python3 harness/check_write_fidelity.py /tmp/canon.tsv harness/corpus/large.smi

判据与 dump_sanitized 那档共用(规范串相同)。变异实测:

注入的缺陷 抓到
删光所有四面体标记 311
一个都不删(保留噪声标记) 0

第二项抓不到是对的,不是判据松:多留一个无内容的标记不改变分子,外部实现 读进去自己会丢掉。这正好印证 genuine_tetrahedral 那条不对称性 —— 删错会丢分子,留错只是输出啰嗦。判据守住的正是危险的那一侧。

同一条规则不许有两份实现

隐式氢的推断规则先前分处两个 crate 各写一遍:

  • omgkit-chem 的净化第 3 步 —— 把氢算出来写回分子。
  • omgkit-io 的 SMILES 写出 —— 决定一个原子能不能去掉方括号(去框之后氢数由 读者按同一条规则反推)。

写出侧那份的注释里明写着"一处已知的不同步":净化侧的 explicit_valence_of 还有一步"芳香价回落"(差在 1.5 以内就取该价态),写出侧没有;并注明"方向是安全的 …… 但两处规则各写一遍、靠人同步 —— 那个结构问题另有任务跟着"。

现在规则搬进 omgkit-core::valence,两处走同一份代码。净化那一步 (跑一遍、写回分子)留在 omgkit-chem

两条规则确有分歧,只是没在写出上显形:large.smi 的 133 537 个(中性、 无同位素、无自由基的)原子里 5 746 处不同,全是"旧的说算不准、新的说补 0 个 氢" —— 典型是并环芳香碳(三根芳香键、键级和 4.5,旧规则拿首位默认价 4 一比就 放弃)。而全语料写出一行没变:那些原子本来就走不到那个函数。

也就是说这次是把一个没在发生的分岔从结构上堵掉。判它有没有堵住的办法是 变异:把裸写规则改成"恒为 0",omgkit-io 的测试当场红;去掉芳香价回落, 全语料写出一行不变而净化侧的测试红 —— 两侧现在共用同一份代码,这两个方向 都碰得到。

判据覆盖面

最难发现的缺陷不是"某道判据判错了",而是藏在判据之间的缝里 —— 一边只测 "净化前的写出",一边只测"净化后的对象",中间"净化后的写出"这条路没人走过。 所以这张表比任何单条判据都重要。

路径 守它的判据
解析(L1) differential_l1*
净化的每一步 / 全管线(L2) differential_l2_*(比分子对象的字段)
写出,不净化 roundtrip_smiles + check_ez.py
写出,净化之后 check_write_fidelity.py
规范 SMILES 的忠实性 check_write_fidelity.py + dump_canonical
规范排序的唯一性 canonical_invariance(重排不变,不需要外部参照)
双键顺反的感知 check_bond_stereo.py
SMARTS 解析(L4) oracle_smarts.py
SMARTS 写出(L3) roundtrip_smarts(幂等 + 规模 + 映射号)+ check_smarts_write.py(语义)
SMARTS 写出的原子映射号 roundtrip_smarts::reaction_templates_keep_their_atom_maps(语料是 reactions.txt)
子结构匹配(L5) oracle_matches.py
产物生成(L7) check_reactions.py
被丢弃原子的收口(L7) omgkit-match/tests/byproduct.rs(判据是质量守恒,不依赖记录)
收口,真实语料 + 外部裁判 check_byproducts.py(自带 --self-test)
产物生成,真实反应语料 + 正逆双向 bench_reactions.py
Python 绑定(L8) test_python.py
SMARTS 手性的参照系 check_smarts_chirality.py(自带区分力检查)
产物侧手性的四种指令 check_product_chirality.py(自带区分力检查)

加新路径时先问这张表:它落在哪一格?没有格子就是新缺口。

有格子还不够 —— 那道判据在不在 CI 里

闸不进 CI 就不是闸。 上表里好几条判官长期只在本地手动跑,而接进 CI 的 那一刻就有红的:

判官 在 CI 里 备注
check_bond_stereo.py 接进来时是红的 —— 五元环内的双键,判官那一侧的参照太宽,见 §双键顺反的感知
check_ez.py
check_write_fidelity.py 只跑 large.smi;非四面体立体那一档由下一行钉着
check_smarts_write.py 自带区分力闸 --min-hits
check_write.py 是(三次) 大语料两个方向 + 冒烟语料。冒烟那次是新加的:大语料里一条非四面体立体都没有,@SP/@TB/@OH 写不出来这件事先前一条判据都没守
check_stereo_perm.py 配位几何的排列分组:9 族 40224 种写法与外部实现逐组比。守的是 polyhedron.rs 那三张表、以及“缺的那个顶点插在哪一位”的标号 —— 本地判据只守得住结构
check_allene.py 是(Indigo) 丙二烯轴手性的分组:3 族 64 条写法逐组比。裁判换了一家 —— RDKit 在这一档上六条路全都分不出 @AL1@AL2
check_baseline_schema.py 基准与生成它的脚本脱没脱钩。三份只比结构,smoke.l3.jsonl 逐字节比(l3 只吐字符串,同版本跨平台一致 —— macOS-arm64 与 Linux-x86_64 实测 sha256 相同)
check_wedge_readback.py / verify_stereo.py
check_reactions.py 22 条刻意分歧按(反应, 底物)逐条钉死;接进来的过程中揪出 3 条真缺陷,见 §与外部实现的一处刻意分歧
check_canonical_fixpoint.py / check_smarts_chirality.py / check_product_chirality.py / test_python.py omgkit wheel:CI 先 maturin buildpip install(maturin 版本也钉在 lock 里)。判据自己打印 omgkit.__file__
check_byproducts.py 要 USPTO-50k 的 templates.jsonl,那份语料不随仓库分发

接进来的同时给这几条补了分母闸(harness/denominator.py,只留一份): 它们原本只数分歧、不数"该数到多少",喂个空文件进去打印一片空白然后退 0。

经 wheel 看 Rust 行为的那几条,门槛高一层

它们 import omgkit,看到的是建出来的 wheel,不是源码。所以:

  • CI 与 gates.sh 都先 maturin buildpip install --force-reinstall, maturin 的版本也钉在 requirements.lock —— 建 wheel 的工具会影响建出来 的东西,而这几条判据的结论就挂在上面。
  • 每条判据自己打印 omgkit.__file__:用户级 site-packages 里躺着旧的一份时, import omgkit静默拿到它。实测开发机上就有这么一份。
  • check_smarts_chirality.py 另有一道自查:wheel 比源码旧就直接退 1。

gates.sh 现在先查 RDKit 版本,对不上就当场停

先前这里只查"有没有这个解释器",理由写着"这批判据两边喂的是同一个 RDKit, 版本变化会对消"。那句话对当时那几条成立(参照侧与读回侧都是 RDKit), 对后来接进来的不成立:check_smarts_chirality.py 一侧是 RDKit、另一侧是 本实现,版本一换就没得对消 —— 见下一节。

追命中率的前提是真值本身是对的

真实语料的"真值"是人记录下来的,不等于化学事实。反向应用模板时最常见的一种 落差是真值欠定:记录里的反应物没标构型,而底物完全定得下来。这时产物带上 构型是对的,拿"完全命中"去要求它等于欠定的真值,等于逼实现丢信息。

实测有一条正是如此:

底物   C(=C/C1=CC=CC=C1)\N1C=CC2=C1C=CC=C2      几何是确定的
真值   BrC=Cc1ccccc1                            记录里没标
本实现 Br/C=C/c1ccccc1                          保住了几何

按字面比,这条算"未命中",而且外部实现"命中"了 —— 它是靠丢掉几何命中的。 把这种情形算进待修缺陷,只会把实现往错的方向推。

所以未命中要先分档,真值欠定的那一档不追。判据是"真值 ⊑ 产物":拿真值当 查询、产物当底物做子结构匹配并开 useChirality,没标的中心放行、标了的必须 一致,再要求原子数相同。成立且产物确实多标了东西,就是欠定,不是缺陷。

同理,"两边一致地错"那一档也不该追:两个独立实现给出同一个答案而都不等于 记录,指向的是模板抽取丢了信息。

手性真值那两列体积,入库以来没有读取方

dump_chirality.py 给每个中心导四样:nbrs(RDKit 的邻居序)、sign(±1 真值)、 vol(以中心原子为基点的行列式)、vol_ligand(四配体行列式,旧口径)。 前两样有五个读取方,后两样一个都没有 —— 从入库到现在,把 vol 全改成 0 也没有任何判据会红。

现有判据只比,而号只有两种取值。实测三种变异 (crates/omgkit-conf/tests/chirality_truth.rs 的模块文档里有表):

变异 chiral::center_volume chiral_oracle
基点从中心原子换成配体 0 整体反 退 1(248 个号对不上)
结果乘 2 不变 退 0,三行数字与未变异时逐字相同

第二行就是补判据的理由:一个差常数因子的体积公式,所有比号的判据一个数都不会动。

补上的三条,全部只读入库的基准、不需要 RDKit:

  1. 数值复算。 用产品的 chiral::center_volume,按真值给的配体序、在真值给的 坐标上复算,必须与 vol 逐个相同(容差 1e-9,实测最大差 2.7e-15,269 个中心)。 同一份输入、同一个定义,Python 与 Rust 两个独立实现必须给出同一个数。
  2. 中心在配体四面体里面。 正四面体上 V_配体 = −4·V_中心,恒反号;同号意味着 中心原子被挤到四个配体张成的四面体外面(伞形翻转),那时 sign 说的构型与 分子实际的构型已经对不上。244 个四配位中心全部反号。dump 在导的时候会数这一档, 但那是导的那一刻的事,入库之后没人再看。
  3. 号不能压在近乎共面的中心上。 无量纲平度 |V|/(|a||b||c|):正四面体是 0.7698(|1−t|·√(1+2t),t = cos 109.47° = −1/3),实测两份基准的中位数 0.7529 / 0.8904,最小 0.5438 / 0.7331,下限钉 0.2。真值若哪天出现一个平的中心, 它的 sign 就是掷硬币,"我们号对了"也就没有意义。

(顺带纠正:chiral.rs::correct_count 的文档先前写着"标准四面体中心约 0.27", 那个数是错的,是 0.7698。它只用来给平度阈值一个尺度感,那张扫描表扫的是 1e-18…1e-2,每一行的结论都不变。)

判据两边的 population 必须先对齐

判官以两侧的并集为准才查得出"本实现少了一条"。可判官往往读整份语料,而 dump 那边可以只跑前 N 个 —— 两个数对不上时,第 N 个之后的输入在判官眼里 全变成"只有基准有结果",凭空多出成千上万条假分歧。

对齐这件事要靠数据自己携带,不能靠文档提醒。所以凡是 dump 可以只覆盖 一部分输入的判据,首行都写 #mols<TAB>N,判官读不到就直接报错。当前 dump_reactionsoracle_matches.py 两处都带这个首行。

harness/baseline/matches.tsv 入库了,而且必须覆盖全量语料

matches_large 解禁进 CI 之后,默认 cargo test 会读这份基准,所以它必须入库 (678 284 字节,large.smi 全部 8839 条)。生成命令 —— 不带 --limit-mols:

# 必须用 requirements.lock 钉的 RDKit 2025.09.2,不是开发机的 .venv(2022.09.5)
python3 -m venv /tmp/rd2025
/tmp/rd2025/bin/pip install --only-binary=:all: -r harness/requirements.lock
/tmp/rd2025/bin/python harness/oracle_matches.py \
    --mols harness/corpus/large.smi --pats harness/corpus/smarts.txt \
    --out harness/baseline/matches.tsv

截断这一件有闸:matches_large 断言 #mols 首行等于语料条数,重导时手滑 加个 --limit-mols 当场红。版本这一件没有闸 —— 生成脚本只是把 RDKit 版本 打在输出里,靠人看;基准文件本身不带版本记录。

它一度只覆盖前 2000 条(22.6%),而那段截断挡住了一条活的分歧。 用 2025.09.2 重导全量之后判据变红:6 条本实现命中而 RDKit 不命中的方向键模式,全落在第 5707 行 那一个分子上 —— 两个稠合五元环,融合处的 C=C 两侧写着 \/,合起来要求 "反式",而五元环里搭不出反式。修在 omgkit_io::stereoMIN_STEREOGENIC_RING (最小环小于八元的双键没有顺反,与 RDKit 的 MinBondRingSize < 8 同一条线)。

顺带记两条实测,crates/omgkit-match/tests/differential.rs 里逐条钉着:

  • 全语料 8839 条里,RDKit 解析失败 8 条、本实现净化失败 8 条,是同一批 —— 跳过它们不损失任何比对。
  • 前 2000 条上 2022.09.5 与 2025.09.2 逐字节相同;差异只在 2000 条之后。

注释里的数字也要有闸门

源码注释里写"实测 N 条"的地方,由 crates/omgkit-chem/tests/documented_measurements.rs 重新量,对不上就红。

cargo test -p omgkit-chem --release --test documented_measurements
# (先前这里写着 `-- --ignored`。那条测试解禁之后,`--ignored` 会把它**过滤掉**,
#  打印 `running 0 tests` 然后**退 0** —— 照着跑的人看到绿,而一个测试都没跑。)

这类数字不会报错,只会静静变成假话,而且往往是被别处的改动带偏的: canon.rs 里"打破对称影响几条分子"那个数取决于写出器写出多少信息,而写出器在 write.rs,两边没有任何东西相连。

数字变了先判断是实现退步还是能力增长(写出得越细,能区分起点的分子 就越多)。确认之后同时改注释和测试里的期望值 —— 只改一处等于把闸门关掉。

SMARTS 写出的语义:check_smarts_write.py

cargo run --release -p omgkit-io --example dump_smarts_written -- \
    harness/corpus/smarts.txt > /tmp/sw.tsv
python3 harness/check_smarts_write.py /tmp/sw.tsv \
    --mols harness/corpus/large.smi --corpus harness/corpus/smarts.txt

Rust 侧的 roundtrip_smarts 测试守的是写出幂等,只能保证"解析→写出→解析 这一趟没丢信息"。一个系统性写错的运算符可以既幂等又是错的 —— 这一档补的正是 那个缺口:两个 SMARTS 都交给外部实现去匹配同一批分子,比匹配到的东西。

原子映射号:三条判据一起瞎了

写出器把 :n 整批丢掉的话:

判据 抓得住吗 为什么
roundtrip_smarts幂等 抓不住 第一趟没 :n、再解析没有、第二趟还是没有 —— 幂等成立
roundtrip_smarts规模守恒 抓不住 原子数与键数一个没少
check_smarts_write.py语义 抓不住 映射号不参与匹配,匹配到的东西一个不差
differential_smarts 抓不住 实测变异下退 0

而这一档丢了就是灾难:产物构建全靠映射号对位,丢掉之后模板还"写得出来、 解析得回、幂等",只是它描述的不再是同一个反应。

语料这一层也是空的:smarts.txt 的 776 条模式里一条映射号都没有。 所以判据加在哪都得先问"这份语料里有没有这东西" —— check_smarts_write.py 现在把 其中带原子映射号 N 打出来,N 为 0 时空过是看得见的。

真正带映射号的语料是 reactions.txt(20 条模板、96 个映射号),由 Rust 侧的 reaction_templates_keep_their_atom_maps 守:逐段比多重集(合起来比的话, 把一个号从反应物挪到产物照样"守恒"),并把条数与映射号总数钉死 —— 语料换成没有映射号的一份时,上面每条断言都会在空多重集上成立。

变异实测:写出器丢掉 :n → 新判据红,differential_smartscheck_smarts_write.py 双双退 0

比的是集合,不是匹配元组

匹配元组的第 k 位对应"查询里第 k 个原子",而写出会重排查询原子的编号, 同一处匹配在两边给出的元组顺序不同。拿元组直接比,报出来的全是编号差异。

三处判据各自要处理这件事:SMILES 往返靠 atom_order 换算、SMARTS 表达式树 改成比幂等、这里改成比 frozenset判据只要碰到"两边的编号可能不同",就得先想清楚比的是不是编号无关的量。

变异实测

注入的缺陷 抓到
;&(写出器要防的那个语义陷阱) 5 / 7
&,(与、或互换) 111
去掉所有 !(否定丢失) 69

第一项的分母是 7,不是全部语料:写出里含 ; 的模式共 37 条,其中只有 7 条在 探针分子上匹配得到东西。剩下 2 条是那处分组差异在这批分子上恰好不显形 —— 语料的局限,不是判据无力。

空匹配不算通过

一条 SMARTS 两边都匹配不到东西时,比出来当然"一致",却什么也没验证。所以 单独统计确实匹配到东西的条数(当前 228 条),低于 --min-hits 就报错。 语料换了、探针分子集换了都可能让判据悄悄变空,这道守卫防的就是那个。

当前:756 条语义相同,0 条不同。

文档也要过闸门

Cargo.toml[workspace.lints.rustdoc] 把断链、私有项链接、冗余链接目标 都设成 deny,所以 cargo doc失败而不是警告。

cargo doc --workspace --no-deps --document-private-items

--document-private-items 不是可选的。 不带它,rustdoc 压根不给私有条目 建档,于是指向私有条目的断链一条都报不出来 —— 闸门看着是绿的。实测一次 审核就在里面捞出三条:两条是新注释里指向私有 fn 的链接,一条是函数改名之后 没跟上的旧名。补上这个参数之后全工作区还剩一条歧义(smarts 底下 parse 既是私有模块、又是 mol::parse 的再导出名),写成 mod@super::parse 消掉。

文档与实现对不上,和代码有 bug 一样是缺陷,只是没人报错就一直堆着 —— 设成 deny 之前积压了 16 条这样的警告。

比断链更难发现的是过期的陈述:机制本身好好跑着,只有理由过期了。典型 形状是"某某尚未实现"在那一步落地之后没人回头改,或者一个字段的归属层次变了 而文档还写着旧的。它会把后来的人引向错误的排查方向,所以巡查时要盯的不只是 "有没有报错",还有"文档说的还成立吗"。

性能守卫要压到真正要守的那段

canon_scaling 有三种压力形状,第三种(带方向键的共轭多烯)针对的是 informative_directions:前两种形状一条方向键都没有,而该函数在没有 方向键时直接返回 —— 守卫从没走进过那段代码。带方向键时它占写出耗时的 64~74%,却完全没被守着。这是"判据空过"的又一个样子:测试跑得好好的, 只是没碰到要守的那条路。

守卫本身也要隔离:跑整个 canonical_smiles 会把方向判定与打破对称的枚举 揉在一起,量出来的涨幅多半来自后者的噪声。只量 directions_for_writing 才稳定,注入平方项能抓到 4 倍涨幅。

守卫要量的那段代码,不能与别的开销混在一起 —— 否则曲线上的涨幅说不清是谁的。

优化之前先看整体占比

cargo run --release -p omgkit-chem --example bench_pipeline 给出各阶段占比 (当前:解析 21%、净化 68%、写出 12%)。它挡下过一次没必要的优化:方向判定 占写出的 10%,看着值得动手,实为整条管线的 1.2%

立体的匹配:一个开关 + 两次口径对齐

MatchOptions::use_chirality 决定查询里写的手性与顺反算不算数。摆成显式字段 是因为影响面大:2000 分子 × 776 模式里 3458 个组合(23%)开与不开结果不同。 本库的取舍:

场景 默认 理由
子结构匹配 作者写了 [C@] 却被悄悄忽略,是最难发现的一类错
run_reactants 不判 反应模板跨工具流通,读得更严会让现成模板不再出产物
递归 $(...) 与外层同一套语义,里外不一致会让 [$([C@](...))] 静默失效

oracle_matches.py 因此也要按"判"的口径生成基准 —— 两边口径不一致时报出来 的全是噪声。

语料要盖得到这条路

判别构型那条路要走到,语料里得有方向键查询原子度 ≥ 3 的 @。 两者缺一,匹配时"换参照系"的那段代码就一次也执行不到,而测试照样全绿。 当前语料 776 条,语义判据的"确实匹配到东西"从 201 涨到 228 正是补上这批的结果。

裸的 [C@H] 刻意不收:查询原子度为 0,测不到构型判别,只会翻出一个已知的 架构差异 —— 参考实现在解析时就把非真手性中心的标记剥掉了,而本库把那件事 留给 stereo::genuine_tetrahedral,不在解析里做。收进来只会报与被测对象无关 的噪声。

真实反应语料上的正逆双向:bench_reactions.py

check_reactions.py 用的是 20 条手写模板 × 分子语料。这一档换成真实反应 数据库导出的语料:三列 CSV 反应id,反应SMILES,逆合成模板,每条自带两侧的 正确答案 —— 模板作用在产物上应当给出反应物,把模板两侧对调作用在反应物上 应当给回产物。

python3 harness/bench_reactions.py <语料.csv> --limit 3000

两侧必须同口径,否则数字是假的

产物生成出来的图两边都是未净化的。直接把未净化的图写成 SMILES 再比很不 公平:两边未净化输出的可读性不同,一侧写得出能读回去的串、另一侧写不出, 比出来的"分歧"其实是净化时机的差别,而且会系统性地偏向其中一边。

所以两侧都先净化再写出,净化不了的一律记成 <unsanitizable> —— 让 "净化不了"成为可比的一类结果,而不是伪装成结构差异。

显式氢要单独分桶

本项目把 removeHs 划在净化之外,显式氢原子留在图里;别的实现通常在解析时 就并掉它们。于是同一条 SMILES 在两边得到的图本来就不同:显式氢把邻接原子 从 D3 撑成 D4,而模板里写着 D3,匹配自然不同。

这是架构差异不是缺陷,但它必须单独统计 —— 混进总体一致率里,那个数就同时 在说两件事,谁也解释不清。实测一致率在两个桶之间差 12 个百分点。

耗时要分段量,而且归一化不能进计时

"跑一条反应"含三件事:编译模板、准备底物、真正做图改写。三者比例差得很远, 混在一起量说明不了任何问题。归一化(净化 + 规范化)更不能算进去 —— 那量的 是判据自己的开销,不是被测对象的。

与外部实现的一处刻意分歧:产物分子数由连通性决定

check_reactions.py 当前报 719 一致 / 22 不同(reactions.txt × large.smi 前 300 个)。那 22 条不是缺陷,是本实现刻意选择的语义,按(反应模板, 底物) 逐条钉死在 DELIBERATE,判据已进 CI。

这段先前写的是 717 / 24,而且说这些"全部形如 [C:1][O:2][C:3]>>…" —— 两样都不对:数目挪过,而占大头的其实是 [C:1][N:2]>>[C:1].[N:2]。文档自己 写着"数目变了要重新查",而没有任何判据在看这个数:这条判官零容差, 直接接进 CI 当场红,于是它一直躺在本地。修法见本节末尾。

例子(环状底物 + 断环模板 → 按连通分量切):

底物   [C@]12(C(OCC1C(=C)CC[C@@H]2O)=O)C          双环内酯
外部   C=C1CC[C@H](O)[C@](C)(C(=O)O)C1 . C=C1CC[C@H](O)[C](C)C1C
       ↑ 两个分子,碳环被复制进两片 —— 原子凭空变多
本实现 C=C1CC[C@H](O)[C@](C)(C(=O)O)C1C
       ↑ 一个分子,开环产物,原子一个不多不少

产物模板描述的是反应中心的片段,不是"一个片段一个分子"。片段在底物里断没 断开,要看模板之外的原子还连不连着。所以所有产物模板建进同一张图、模板之外 的原子只搬一次,最后按连通分量切开。

判据是质量守恒:产物的重原子总数不该多于底物。逐产物各搬一次的话,共享的 那一段被复制进每个产物,而拓扑、价键、立体全都自洽,只有原子数不对 —— 没有 任何一档判据在看这个数,所以专门加了 omgkit-match/tests/reaction.rsproducts_never_invent_atoms

这 22 条按(反应模板, 底物)逐条钉死,不设上限 —— 上限只能变松。名单 三个方向都会红,都变异实测过:

变异 判据
名单外多一条分歧(新回归) 红,"名单外的分歧 1 条"
名单里有一条不再分歧(语义变了 / 改动被撤销) 红,"名单里却没红的 …"
名单里有语料够不着的条目(语料改了没跟) 红,"名单里有 N 条语料够不着"
dump 只覆盖 50 个分子(分母缩水) 红,MIN_MOLS

没进 CI 的那段时间里,25 条里混进了一条真缺陷

把 25 条按"抹掉立体之后两边一不一样"分类,4 条只差立体 —— 那与连通分量 的故事完全无关,是被它盖住的另一档。

3 条是双键顺反被整个作废(真缺陷,已修):

反应   [C:1][N:2]>>[C:1].[N:2]
底物   [N+](/C(=C/c1ccccc1)C)([O-])=O          硝基烯烃
外部   C/C=C\c1ccccc1 . O=[NH+][O-]
本实现 CC=Cc1ccccc1   . O=[NH+][O-]            ← E/Z 整个没了

顺反记在双键上,可它是相对两个参照原子说的。这里的参照就是要走的硝基氮, 而它走掉之后没有别的原子顶它的槽位(空位由隐式氢补,隐式氢没有下标、做不了 参照)。先前这时候直接放弃整根键的顺反 —— 而双键还在、两端各自还有取代基, 构型依旧成立。正确做法是改参照到该端另一个取代基,并把顺反翻一次(它在 双键的另一侧)。判据:omgkit-match/tests/reaction.rsbond_stereo_rebases_to_the_other_side_when_nothing_fills_the_slot

剩下 1 条是外部实现错,不是我们错:

反应   [C:1][N:2]>>[C:1].[N:2]
底物   [H]/N=C(\C#N)/[C@@](C)([NH+](C)C)SC     无环
外部   … [H]/N=C(\C#N)[C@@H](C)SC
本实现 … [H]/N=C(\C#N)[C@H](C)SC               ← 号相反

四配位手性中心被摘掉一个取代基、空位补隐式氢,补进哪个槽位决定剩下的构型。

谁对不是推出来的。 判官是几何:把底物嵌成真实三维构象,把离去的氮 原地换成氢,再用 AssignStereochemistryFrom3D 读回构型 —— 五个 seed 全给 [C@H],与本实现一致。这条判官先自校准过(同一条路读底物本身,五个 seed 都 还原输入的 @@),所以它报的号是有依据的。顺带:RDKit 在图层面直接摘掉 那个氮时给出的是 [C](标记整个丢掉),[C@@H]RunReactants 自己的记账。

上面那 3 条硝基烯烃也是同一条几何判官定的案,那三条外部实现与几何一致。

SMARTS 手性:判据必须先证明自己分得出正反

check_smarts_chirality.py 守的是"查询里写的 @ 落在哪个参照系上"。这一档 比别的都容易变空:手性错误不改拓扑、不改原子数、不改价键,匹配数可以 分毫不差而标记早已翻转。

所以每条查询先过区分力检查 —— 它与它的镜像必须匹配到不同的东西,两侧 各自都要满足。不满足的不计入结论,单独报成语料缺口;缺口超过上限直接失败。

建这个判据时踩到的三个坑值得记下,它们都会让判据静默变空:

症状
"@@" in s 判断该往哪翻 串里后面的中心写成 @@ 时命中它,翻的是另一个原子
命中只记原子下标 探针集里成对放着对映体,两边的下标元组一样,区分力全线归零
只比命中 数目相同但命中的是不同分子,那仍是分歧

跑之前必须重建 wheel

这一档量的是 Rust 侧的解析行为,而它通过 wheel 看到。改完 Rust 不重新 maturin build + pip install --force-reinstall,量到的是上一次的产物, 结论会稳稳指向错误的方向且毫无迹象。

实测踩过一次:同一份源码前后量出两组不同的数(48 反/0 空过 与 52 反/40 空过), 根因是撤回源码后没重建 wheel —— 那次当作"基线"的测量,跑的其实是带补偿的 旧构建,据此得出的"环闭合不是根因"是错的。

凡是经 wheel 观察 Rust 行为的判据,都要先重建再测量。

这一档换 RDKit 版本会翻结论 —— 而且不是本实现的锅

拿开发机的 RDKit 2022.09.5 跑,这条判据报 48 条"反了",而且分布得 干干净净:全部落在"首原子 + 括号氢"那三格(首有H 0环/1环/2环), 其余六格 56 条一条不差。拿仓库钉的 2025.09.2 跑,104 条全对、0 空过。

谁对不是投票定的。同一批串,问 RDKit 自己:

当 SMILES 读(两版一致) 当 SMARTS 匹配 C[C@H](N)O
[C@@H](C)(N)O 规范成 C[C@H](N)O 2025 ;2022 不中(它中对映体)
[C@H](C)(N)O 规范成 C[C@@H](N)O 2025 不中;2022
C[C@H](N)O(非首) 规范成自己 两版都中

2022 的 SMARTS 与它自己的 SMILES 读法自相矛盾,2025 修好了。规范是: [C@H] 的括号氢占"紧跟前一个原子"那一位;首原子没有前一个原子,氢因此落到 第一位,与 C[C@H](...) 差一次对换。本实现按规范做,与 2025 一致。

因此 harness/gates.sh 现在先查 RDKit 版本,对不上就当场停:一侧是本实现 的判据,版本一换就没得"两边对消"。同一条约定另有一条不依赖 RDKit 的判据 守着 —— omgkit-match/tests/basic.rsa_leading_bracket_h_is_read_the_same_in_smarts_and_smiles,它断的是 "SMARTS 与 SMILES 同一个读法",而 SMILES 那一侧由全量差分判据独立守着。 变异实测:把首原子那一支关掉(= 2022 的行为),这条判据当场红。

当前状态

104 条生成查询全部一致,0 空过(RDKit 2025.09.2)。

真基线(无任何补偿)是 52 条与外部实现相反、40 条空过。单独补哪一项都不行: 只补环闭合置换会翻掉本来正确的那些,只补括号氢更差。要两项一起补,而且 括号氢那一项只在该原子写了三根键时补 —— 写得更少时四个位置凑不齐,标记在 查询自身的范围内定不下构型,补一次反而凭空翻掉一次。

解析补一次、写出必须补回去,否则 解析 → 写出 → 解析 连翻两次。两处因此 共用同一份判定(smarts::mol::needs_h_compensation)。

产物侧的手性:四种指令,不是一个字面值

check_product_chirality.py 守的是反应模板两侧写没写手性各自表达什么:

反应物侧 产物侧 含义
没写 没写 模板没管 —— 底物的构型带过来
写了 没写 构型被破坏
没写 写了 构型是新建的,与底物无关
写了 写了 相对底物保留(两标记相同)或翻转(不同)

最后一行最容易做错。把产物侧那个标记照字面写死的话,同一个模板作用在一对 对映体上会给出同一个产物 —— 而正确答案是一对对映体。

这条路在仓库语料上的触发面是 0

corpus/reactions.txt 的 22 条模板没有一条在产物侧写手性,所以反应差分 (check_reactions.py)跑得再多也照不到这段代码。真实模板里这却是常态: 立体专一的反应正是靠产物侧的 @ 表达构型的。用例因此写死在判据文件里。

区分力靠"产物随不随底物变"

用例成对给出对映体底物:"新建"那一档两个底物必须给出同一个产物,"保留" 与"翻转"那两档必须给出不同的产物。不满足就是空过,单独报出来。

邻居"挪走了"也算腾出槽位

重定基要把反应物的邻居序换成产物的邻居序,中间要判每个槽位空没空。判据是 还连不连在这个中心上,不是那个原子有没有进产物 —— 反应可以把一个邻居挪到 另一个产物片段去、同时接上一个新的:

[C@@H:1]-[O:2]>>C-C(=O)-O-[C@@H:1].[OH:2]

氧带着映射号活到了另一个产物里,只是不再连着 :1。只判"进没进产物"的话这个 槽位算被占着,于是反应物侧的邻居集合里有个产物侧没有的原子,置换的多重集对 不上,重定基被静默跳过 —— 标记原样照抄,得到镜像。

这一条在真实语料上一次就修好 6 条("纯立体、外部命中"从 7 条降到 2 条)。

判据必须是绝对的,还要挑置换为奇的用例

写这条 Rust 测试时空过了两次,两次都是绿的而缺陷还在:

空过的写法 为什么看不出来
拿"模板不写手性"当参照 它走同一段重定基,同一个缺陷把两边一起带偏
底物写成 CO[C@@H](C)CC 走掉的氧与接上来的氧占同一位置,置换是偶的,漏掉重定基也无妨

正确的写法是直接写出应得的产物,并且把醚氧摆在中间那一位 (C[C@@H](OC)CC),让置换成奇。

当前状态

四种指令 7 条、真实模板形态 5 条,全部一致,0 空过(--max-bad 默认 0)。

顺反那 46 条:模板说不出的,谁都造不出

反应侧还剩 46 条"抹掉顺反就完全命中"的。要分清两件事:那根差的双键是模板新建 的(底物那里根本不是双键,几何无从继承),还是从底物搬过来的(几何本来是确定 的,丢了就是实现的问题)。

按映射号对判"新建还是搬运"会漏

第一版判据只认两端都有映射号的键:把反应物模板与产物模板各自的映射号对收成 表,产物侧有、反应物侧没有的算新建。模板里这种写法就漏掉了:

>>...-C(/C=[CH;D2;+0:1])-...

双键的一端是模板新加的原子,没有映射号,于是这根键根本进不了表,"新建"里查不到 它,反倒被算成"搬过来的"(反应 576 就是这样)。按这个口径数出来的"14 条搬运丢了" 是虚高的。

判据改成按运行时的原子对应:run(..., atom_mapping=True) 会给底物副本与产物的 对应原子打同一个号,产物侧新建的原子拿不到号。于是

  • 产物的一根双键,两端都有号、且底物里这两个原子之间也是双键 → 搬运
  • 否则 → 新建

搬运的还要再问一句:底物那根双键本来有没有几何。没有的话丢不丢都无从谈起。

结论:46 条里一条实现缺陷都没有

条数
模板新建双键,方向只写了一端或压根没写 31
搬运,但底物那根双键本来就没几何 4
记录的几何在反应里变了(Z→E),模板没写这个变化 1
记录没标而本实现标了 —— 记录欠定 11

再逐根键与外部实现对照(同构的两张图用对立体不敏感的规范秩建双射,逐键比 E/Z):

  • 没有一根键是外部定得下而本实现定不下的
  • 有 1 根反过来:本实现定得下、外部定不下
  • 另有 9 条外部实现连骨架都对不上

一根方向键定不下几何

Br\[C;H0;D3;+0:1]=[CH;D2;+0:2] 这种产物模板,方向只写在双键的一端。顺反是双键 两端取代基之间的关系,一端写了"在上侧"、另一端没有任何东西与它比,几何是 未定义,不是"待推断"。

这条判断一开始被自己的检查推翻过:检查数的是模板里所有双键,而不是真正差的 那一根。反应 9922 的模板

>>[CH:1]=[CH:2]/[CH:3]=[CH:4]/[C:5]#[C:6]/[CH:7]=[CH:8]/[CH:9]=[CH:10]

里,中间两根双键两端都有方向,首尾两根只有一端。逐根一看:两端都写了的两根, 两个实现都造出了 E;只写一端的两根,两个实现都留空,规范式逐字相同。这比"没有 反例"强 —— 它同时证明了写全了就落实得下、写不全才落实不下,判据没有空过。

带映射号与不带映射号是两条路,不能对着比

诊断脚本一度多报了 6 条"手性被翻成镜像"。查下去:同一个分子,带映射号解析与不带 映射号解析,外部实现给出的规范式落在两种等价写法中的不同一种 ——

COC=C1[C@H]2CC[C@@H]1CC2      不带号
COC=C1[C@@H]2CC[C@H]1CC2      带号,去号后

两串的 InChI 一模一样,是同一个化合物。降冰片烷桥头那对中心只有相对构型有意义, 映射号打破了分子的对称性,"这算不算真手性中心"的判定跟着变。

所以要在文本层:N 去掉再解析,而不是解析完再清映射号属性——后者留下的残余 状态足以让写出器换一种写法。去号后原子次序逐字不变,两次解析的下标一一对应,可以 拿带号那份查号、拿不带号那份比化学。

这个坑有个好抓手:修之前总数 52、按档只加到 36;修之后总数 46(与旧口径逐条对齐)、 46 条全部落档。账对不平就是判据有问题,不要当噪声放过。

两侧都写手性:比标记之前要先对齐邻居次序

四种指令(继承/清掉/写死/保留-翻转)本身早就对齐了,可"保留还是翻转"这一步原先 只比两侧的标记字面值:

[F:2][C@H:1]([Cl:3])[Br:4]>>[Br:4][C@H:1]([Cl:3])[F:2]

两侧都是 @,取代基一个没换 —— 按字面比是"保留"。可产物模板把 F 与 Br 对调着 写了,而标记说的是"按这张模板自己的邻居顺序看过去"的构型,置换是奇的,所以这 条模板说的其实是翻转

判据改成:先按映射号把两侧的邻居次序对齐,算出置换宇称,再与"标记相同不相同" 异或。每侧至多容忍一个对方没有的邻居(它们互相顶替,对应唯一);多于一个时不 调整。

这个缺陷在真实语料上触发面是 0

修完命中率一条没变(完全命中 2269、未命中 139、omgkit 独有未中 3,逐项相同)。 抽出来的模板习惯按同一条路径书写两侧,次序自然对齐。触发面为 0 不等于不用修 —— 手写模板、别处来的模板都可能对调着写,而错了的症状是产物变成镜像:原子数、键、 连通性全对,纯拓扑比对永远发现不了。

判据:check_product_chirality.py 的"次序对调"三条 + Rust 的 both_sides_written_are_compared_in_a_common_neighbour_order。后者验过不空过 —— 把宇称那一项去掉,它当场变红。

一次换掉两个取代基:这是约定之争,不是缺陷

同一个中心上同时换掉两个取代基时,"谁顶替谁"有两种配对,宇称相反,得到的是一对 对映体。拓扑本身说不出是哪一个。

要紧的是别把另一种做法误认成"不猜":"对应不唯一就不重定基"同样是一个约定 —— 它把标记原样留在底物的参照系里,等于猜了恒等置换。两者都推不出来。

所以只能拿记录裁。两种约定在语料上分歧 3 条:

反应 依次顶替(本实现) 不重定基
6855 与记录一致 反了
11651 与记录一致 反了
13618 反了 与记录一致

2:1,所以保留现在的做法,并把这件事写进 fill_replaced_slots 的文档。反应 13618 原先挂在"唯一一条纯立体、外部命中"的账上,至此结清:不是缺陷,是这条约定在这 一条上恰好判错。

顺带:6855 与 11651 是"本实现对、外部错",已经在 dev/advantages.jsonl 里。

量它的办法

改动只影响一处判据时,与其比总数,不如逐条 diff 判定:两个构建各 dump 一份 反应号 → 判定,取差集。总数从 2269 变 2268 只说明净少一条,看不出是"修好 1 条、 弄坏 2 条"还是"弄坏 1 条"。差集当场给出那 3 个反应号。

手工断键:一个不经过任何反应引擎的判据

反应 7187 的产物与记录差在两个中心上,而且两个一起翻 —— 整体是记录的对映体。 这种形状有两种解释:重定基把参照系换错了,或者记录本身不自洽。分不开就没法判。

分得开的办法是绕过反应引擎:模板要断哪根键、改哪个官能团是写明的,那就直接在图上 做这几步

m = Chem.RWMol(Chem.MolFromSmiles(substrate))
m.RemoveBond(5, 7)                                              # 打开四元环
m.GetBondBetweenAtoms(5, 6).SetBondType(Chem.BondType.DOUBLE)   # C-OH → C=O
for i in (5, 7):                                                # 这两个中心模板要清掉
    m.GetAtomWithIdx(i).SetChiralTag(Chem.ChiralType.CHI_UNSPECIFIED)

要紧的是两个有争议的中心一个字节都没动:断的是别处那根键,它们的键序不变,所以 手性标记原样有效。这条路子里没有本实现、也没有外部实现的反应代码。

结果是本实现的那一个,不是记录的那一个。反应 7187 是记录不自洽 —— 它记的产物, 在那两个旁观中心上是它自己记的反应物的镜像。追命中率到这里就该停:迁就它等于把正确 的构型改成错的。

这个判据也要证明自己不空过

把底物那两个中心翻掉再走一遍,结果正好变成记录的反应物。翻了还给同一个答案的 话,说明判据根本没在读那两个中心 —— 那样得出的"记录不自洽"是假的。

"两边错得不一样"这个口径偏宽

那一档原先比的是全部产物组:两边只要有一组不同就算。可产物组数取决于底物上有 几处反应位点、以及要不要去重,本来就允许不同。要归因的其实只有与记录同骨架的那 一组 —— 别的组不参与判定。

按后一个口径重扫手性那 14 条,分档完全变了:

条数 含义
外部给不出同骨架的组 7 本实现给得出,轮不到说"两边不一样"
两边给出同一套差异 4 一致地错 —— 记录或模板的事
两边的差异不同 4 才是要查实现的

判据放宽一点,要查的量就会翻好几倍,而多出来的那些查下去全是空的。分档判据本身 也要挑口径。

那 4 条"两边差异不同"与"受约定影响"的是同一组

两次独立的量给出了同一组反应号 {713, 6986, 8544, 12186}:

  • 把"一次换两个取代基"的约定换成另一种,产物变了的就是这 4 条
  • 只看与记录同骨架的那一组逐中心比,两个实现差异不同的也是这 4 条

所以手性那 14 条至此全部有归属,没有已记录约定之外的缺陷:

条数
外部给不出同骨架的组 7
两边一致地错 —— 记录或模板的事 4
双取代约定那一档 4

那一档里 6986 上本实现反而更好(外部多错一个分子),8544 与 12186 各多错一个中心, 713 两边各错 4 个但错的不是同一批。约定本身已按记录 2:1 定过,不再动。

判"保持还是翻转"要自己重算,别比标记

剩下那 6 条(外部实现连同骨架的产物组都给不出,没法互相印证)只能靠重算裁。判据是: 按运行时映射号把底物与产物在该中心上的四个邻居(氢显式化)对应起来,自己算置换 宇称,推出"保持构型"该写哪个标记 —— 实际标记等于它就是保持,等于它的反面就是翻转。

这一步不能省成"直接比两个分子的手性标记"。标记是相对各自邻居存储顺序的,跨分子 比出来的是写法差异,不是构型差异。写这个脚本时先踩了一次:同一条反应,比标记报出 3 个中心不同,比 CIP 只有 1 个 —— 后者才对(CIP 只依赖构成,同构的两张图之间可比)。

结果:六条的翻转中心数恰好等于模板的 Invert 指令数,两个方向都对得上(既没多翻 也没漏翻)。所以差异全在模板说不出的地方,不是实现的问题。

只看"差的那些中心"是单向筛子 —— 抓得到"翻多了",抓不到"该翻没翻"。要把产物里 每一个能追回底物的中心都算一遍,两头才都堵上。

判据本身也验过:拿四条答案已知的模板(两侧都没写 / 两侧同标记 / 两侧异标记 / 带括号 氢的异标记)跑,该"保持"的给保持、该"翻转"的给翻转,一条不错。

派生属性不能从底物照抄进产物:自由基电子数

反应侧最后一条"骨架不对而外部对"(净化失败)查到的是这个。

底物里 [C](三价中性碳)带一个自由基电子。模板把它改写成芳香碳,而 [num_radical_electrons] 被原样继承了下来。净化里 kekulize 排在自由基重算 之前(自由基数要等键级定下来才算得出),于是这个陈旧值被 kekulize 当真:它认为 那个碳不能再要双键,整个苯环配不出 Kekulé 结构。

症状离根因很远:报错落在环上另一个原子身上,而且只在反应路径上犯 —— 把同一个 产物写出来再读回去是好的,因为新解析的分子这个字段本来就是 0。

自由基电子数是派生量:由具体的 Kekulé 结构、电荷、氢数一起定下来,而模板恰恰 会把这三样都改掉。所以产物侧一律清成 0,让它走与"写出来再读回去"完全相同的路。

最小复现:

底物  CN(C)[C]1C=CC(C=C1)=[N+](C)C
模板  [N+;H0;D3:1]=[C;H0;D3;+0:2]1-[CH;D2;+0:3]=[CH;D2;+0:4]-[C;H0;D3;+0:5]
      -[CH;D2;+0:6]=[CH;D2;+0:7]-1>>[N;H0;D3;+0:1]-[c;H0;D3;+0:2]1:[cH;D2;+0:3]
      :[cH;D2;+0:4]:[c;H0;D3;+0:5]:[cH;D2;+0:6]:[cH;D2;+0:7]:1

判据 radical_count_is_not_inherited_from_the_substrate,验过不空过(去掉清零当场 报出同一条 kekulize 错误)。真实语料上完全命中 2269 → 2270

推论:凡是"由结构算出来的"字段,产物侧都不该继承。这一条值得在加新字段时回头 看一眼 —— 症状是净化在别处失败,不会指向真正的出处。

规范 SMILES 曾经依赖输入写法

CCO[CH3][CH2][OH] 是同一个分子,规范式却给出两串。根因:写出器按 AtomFlags::NO_IMPLICIT 决定加不加方括号,而那是输入写法留下的痕迹,不是分子的 属性。规范式的全部用途(去重、哈希、跨来源比对、当字典键)都要求"同一个分子 → 同一串",这条不成立,而且不报错。

影响面比看上去大:SMARTS 的原子天然写在 [] 里,所以每个反应产物的规范式都是 方括号形式(CN(C)[c]1[cH][cH][c](...))。

两条性质冲突,所以由调用方选

要的性质 判据
BracketStyle::Faithful 往返恒等 —— 再解析得到的原子逐字段相同 roundtrip_smiles.rs
BracketStyle::Minimal 规范 —— 同一个分子只有一串 canonical_is_independent_of_bracket_notation

冲突是实打实的:[CH3][CH2][OH]CCO 是同一个分子,却不是同一份原子表示。 往返判据逐字比 num_explicit_hsNO_IMPLICIT,它要保住这个差别;规范化要抹掉它。 所以不能挑一个,得两个都留着,由 smiles::write(忠实)与 canon::canonical_smiles (规范)各取所需。

"去框"的判据只要三样,不要价键模型

原先的注释说这需要 L2 的价键模型、L1 拿不到,所以推迟。这个理由是错的:判据只要 键级和 + 总氢数 + 该元素的首位默认价,三样都在 L0。

  • 带氢时:键级和 + 氢数 == 首位默认价 —— 反推出来正好是这些氢
  • 不带氢时:键级和 >= 首位默认价 —— 价已填满,反推出来也是 0 个

第二条不能省成"没氢就随便去框":三价的中性碳([C])氢数是 0,去框写成 C 再读 回来就补上一个氢,分子当场变了。

两条护栏:只去框、不加框(加框要知道简写形式会推出几个氢,未净化的分子上那个数 还没算出来);算不准的一律留框(碰到配位键、无价约束的元素直接留)。留框只是 啰嗦,去错了会改掉分子。

判据的洞不是判据松

check_write_fidelity.pydifferent_spellings_converge_to_one_canonical_form 在修之前都是绿的。前者的语料里没有"给常见原子多写方括号"的形态;后者换的是遍历 起点与分支顺序,原子的表示从没变过。所以补的是一条新判据,不是放宽旧的 —— 而且它验过不空过(把规范化路径改回 Faithful,当场变红)。

配套还要一条反向判据(brackets_that_carry_hydrogen_counts_are_kept):该留的框不能 被"能省则省"去掉。只有正向那条的话,一个把所有框都去掉的实现也能通过。

规范化的不动点:check_canonical_fixpoint.py

规范化最核心的一条自指不变量:把规范串重新读进来再规范化,必须还是同一串。 不需要任何外部参照。

现有判据盖不到它:

  • canonical_smiles_is_invariant_under_renumbering_corpus 换的是分子对象的 原子编号与键序,不经过"写出再解析"这一趟
  • 而那一趟会同时改掉原子次序、键序、氢的表示,以及方向键写在哪根键上

第一次跑就抓到 8831 条里 118 条不成立。外部实现在同一条不变量上是 0 条 —— 所以不是"这条性质本来就做不到"。

顺带守第二条:净化幂等(净化两次与一次相同)。实测 0 条不幂等。

根因:方向键的整体翻转自由度被存储下标定了

同一约束片段内 /\ 可以全体互换而不改变任何一对取代基的相对位置 —— 对分子而言这是个真自由度。directions_for_writing 原先按键的存储下标取种子 (片段内第一根键取 UpRight),而存储下标是输入写法留下的痕迹。

改成由输出顺序定死:规范写法下,每个片段第一个写出来的方向符号一律取 /。 自由度必须定死在看得见输出的地方,写出器才是那个地方。

118 条 → 2 条

曾经残留 2 条:参照原子的挑法也带着输入的痕迹 —— 已修

perceive_bond_stereo 挑参照原子时取的是"存储顺序里第一个带方向的邻居",所以方向 写在哪一侧,参照就落在哪一侧。双键一端挂着两个取代基时两种挑法都对,写出来的方向 符号却落在不同的键上。

实测这样的 2 条,几何完全一样(外部实现读三串都是同一个分子),差的只是一个不 携带信息的方向符号,而且第二次起就稳定。

已修。 修法与全局翻转那一条同源:把自由度定死在一个与输入编号无关的判据上。 规范写法下,参照原子改按规范秩挑(stereo::normalized_stereo_refs),顺反跟着 换算 —— 换一个参照、把顺反翻一次,说的是同一件事。于是写出器看到的 stereo_atoms 只取决于(图, 几何),而这两者往返无损,write(parse(write(m))) == write(m) 就 自动成立,不必再去管感知那一步挑了谁。2 条 → 0 条,--max-bad 默认改成 0。

钩子放在 write_with_priority_styled:那里拿到的 priority 正是 break_all_ties 之后的离散规范秩。只动 Cis/Trans(本就相对参照原子说的),Z/E 按 CIP 定义、 与参照无关,不碰。忠实写法完全不动 —— 那边要的是原样再现输入。

顺带确认过不构成循环依赖:颜色细化的 stereo_descriptor 只读四面体手性, 不读 stereo_atoms,所以按秩改参照不会反过来改秩。

判据差点空过

Rust 侧补的判据 canonical_smiles_is_a_fixed_point 第一版只 parse,没有净化与 感知 —— 撤掉修复照样绿。stereo_atoms感知填的,不感知就压根走不到参照 原子那条路。改成走调用方真正走的那条路(净化 + perceive_bond_stereo)之后, 撤修当场变红,报的正是记录在案的那两串。

判据要走真实调用路径,不是走最短能编译的路径。

顺带一条方法:零触发面的新分支是负担,不是保险

查残留时先按"约束表非空 ⇒ 感知跑过 ⇒ 没进约束的方向不携带信息"收紧了一次,结果 条数一条没变 —— 说明那条路径在语料上根本不走。没有判据盖到的分支留着只会腐坏, 所以撤掉了。改动要有可观察的效果,否则它只是又一处没人验的代码。

USPTO-50k:五万条反应的正逆双向

benchmark/uspto-50k/ 是本项目规模最大的一次差分:USPTO-50k 全量 50016 条, 正向与逆向各跑一遍,与 RDKit 的 RunReactants 逐条对比命中与耗时。完整结论、 公平性口径与复现步骤写在那个目录的 README,这里只记判据本身的几处教训。

那套材料不随本仓库分发 —— 它带着上百 MB 的语料与逐条结果,不适合与库同仓。 它自足:脚本一律以自己所在位置定 ROOT,放到哪都能跑。

一、裁判要选对手的规范式,不能选自己的

命中与否一律用 RDKit 的规范 SMILES 判:omgkit 的产物先写成 SMILES, 再交给 RDKit 读回并规范化。用自己的规范式判自己是自证;代价是要多过一道 "写出 + 被对方读回"的关,写错了就算未命中 —— 这个偏差对本实现不利, 正是想要的方向。

二、CIP 不能拿来比构型,有两处

跨反应不能比。 CIP 按取代基优先级排,而反应会换掉取代基 —— 同一个空间构型, 反应前后的 CIP 字母完全可以不同。跨反应一律用映射号邻居序的置换宇称: 构型相同 ⟺ (标记相同) == (置换为偶)。

同一个分子内也不能比。 相邻的两个中心一起变时 CIP 会连带翻个个儿,一处真差异 虚报成两处。实测这一项让"引擎翻了"从 11 条虚增到 269 条 —— 判据本身错了 24 倍, 而它报出来的每一条看着都像真缺陷。改成"补上显式氢,再比标记加真实邻居序的宇称" 之后才收敛。

判据错得比实现错更危险:它会把人引到本来正确的代码上去改。

三、逐条归责会把一处错算到整条头上

一条反应里可以同时有好几处立体分歧,成因各不相同 —— 一个中心是记录自己前后矛盾, 另一个才是实现没落实。按整条归责,只要有一处沾了"模板写了",整条就算实现的 问题:实测这样会把 126 条记录问题误判成实现缺陷。必须逐个立体中心定责。

四、"模板写了方向键"不等于"模板表了态"

归责脚本一开始用"模板串里有没有 /"判模板是否指定了几何。孤零零一根 / 定不了任何几何(F/C=CF 就是),按这条判会把一大批记录问题算到实现头上。 判据必须与 Rust 侧的 honoured_directions 同一条:双键两端各有一根方向键 才算表态。改对之后疑似缺陷从 126 条降到 27 条。

五、取数的键必须与写数的键同源

explain_misses.py 一开始按 CSV 的第一列取原始记录 —— 而那一列不是行号, 50016 行里有 49532 行对不上。取到的是另一条反应,而后面每一步都照样跑得通, 报出来的"引擎缺陷 754 条"全是假的。

这类错没有任何东西会报警。凡是跨文件按下标取数,都要先验一遍两边同源。

六、找一个有资格的第三方来裁

疑似实现缺陷剩到 44 条时,靠"omgkit 与 RDKit 谁对"已经判不动 —— 两边都可能错。 这批模板是 rdchiral 抽的,模板里的手性标记也是它写的,所以"这条模板该怎么读" 这个问题,它自己的应用器给出的就是作者本意。44 条交给它裁,44 条它自己也 还原不出 —— 全部是模板的极限,零条是本实现没做到。

判据链因此闭合:1288 条未命中逐条有责,骨架零出错。

七、只查未命中,就查不到"多输出一个错答案"

上面那条闭合的判据链有个口径限制,当时没写清楚:它归因的是未命中,回答的是 "没中的时候为什么没中",而不是"输出对不对"。

一条逆向模板在同一个底物上通常匹配到好几个位点,每个位点给一组产物。只要有 一个位点给出与记录一致的答案,反应就算命中 —— 其余位点给出什么,命中率一个字 都不说。后来查出的键归属缺陷正好落在这个盲区:70 条反应里,绕环的那些位点把 分子多切了一刀,而不绕环的位点给出正确答案,于是

  • 命中率:修前修后一条不差
  • 质量守恒:原子没多没少,只是连通性变了
  • 净化检查:70 条里只有 8 条净化不过
  • 未命中归因:这些反应都命中了,根本不进归因集合

四道口径同时失明。最后是把新旧两版的预测集内容逐条对拍才查出来的 —— 而基准 记录里只存了预测集的条数,连"条数相同、内容变了"都盖着。

两条教训:

一、命中率是个存在量词。 "真值在预测集合里"只约束集合里什么,不约束 集合里还有什么。要约束后者,判据得换成"预测集合等于什么"或者逐条对拍。

二、改动的影响范围要能预先算出来,再与实测对账。 这次先扫全语料定出"哪些 反应满足触发条件"(70 条),再看实测有变化的是哪些 —— 两个集合对上了,才敢说 没有想不到的副作用。第一版扫描把范围报成 78 条,原因是把"模板里没有映射号、 本来就要删掉的原子"也算了进去;对不上账就得回头查判据,而不是把差额搪塞过去。

八、频次论证最容易混进不该数的东西

论证"某个缺口值得认真对待"时,顺手报一个占比是最省事的做法,也最容易错。

这里错过一次:为了说"盐不是边角情形",数了"反应物侧含小离子或带电片段"的 记录 —— 3625 条、7.2%。数字本身没算错,但它数的东西不是论点要的东西。逐条 对着记录的参与反应分子集合判了一遍,那 3625 条全部是真反应物(甲醇钠、 氨、甲醛、铵盐、Wittig 鏻盐、格氏试剂),旁观反离子 0 条。再查一层,语料 抽取时早就把多组分物种拆成了独立分子,spectators 字段 50016 条全为空。

也就是说:这份语料在结构上就不可能包含那一档,而我拿它的一个统计量去论证 那一档很常见。

两条:

判据要能把"论点要的那一类"与"长得像的那一类"分开。 . 在 USPTO 的串里 同时表示"另一个反应物"与"同一个盐的另一半",文本上分不开 —— 分不开就不能拿 它当频次证据,得换一个能分开的判据(这里是"在不在参与反应分子集合里")。

表达力论证与频次论证是两回事,别混着说。 "契约写不出同时碰阳离子与阴离子 的模板"只依赖契约的形状,不依赖任何语料,它照样成立;成立的是它,不是"语料上 占 7.2%"。把两者绑在一起,前者会被后者拖着一起垮。

九、"另一个实现能做到"这句话要去读它的源码

记过一条"两个引擎都跑不了这 301 条,rdchiral 可以,它把整个分子交给匹配"。 后半句是错的。 rdchiralRun 内部是 rxn.RunReactants((合并后的分子,)) —— 传的是一个 1 元组,所以 RDKit 仍然要求模板只有一个反应物片段;301 条逐条 跑过,全部抛同一个 "Number of reactants provided does not match"。

错法很典型:rdchiral 确实"把整个分子交给匹配"(它不按位置拆反应物),这一点 没记错;错在由此推断它因此能处理多片段模板。两件事只是听起来相邻。

判据:能跑就跑一遍,跑不了就去读源码,别从设计描述往能力上推。 这条与 第六条("找一个有资格的第三方来裁")是配套的 —— 请第三方裁之前,先确认它 在这个问题上真的有发言权。

十、归因表要与总数对账,否则它只统计"能比的那些"

把归因表加了一遍:未命中 1288 条,而逐个立体中心定责只出 1286 条。

差的 2 条预测集是空的。定责脚本要拿预测与真值逐中心比,没有预测就无从比起, 于是被静默跳过。而"一个产物都没有"恰恰是最重的一档 —— 归因表看着干净,是因为 它只统计了能比的那些,并且不说自己漏了什么。

那 2 条查下去是一处真缺陷:/ \ 的键级判据写成了仅单键,比 SMARTS 的 默认键("单键或芳香键")还严,于是模板把方向落在一根芳香键上时一处也匹配 不上。修完逆向命中 49400 → 49402,缺口归零。

三条可复用的:

  1. 归因表必须与总数对账,并且把对账写进脚本(summarize.py 现在算 miss_attribution_gap,非零就报警;拿修前数据跑会报"有 2 条没被归因", 所以对账自身也非空过)。留给人去加一遍,就等于不加。
  2. "比不了"要与"比过了、没问题"分开记。 静默 continue 会把前者伪装成后者。
  3. 收紧比放宽更难发现。 放宽会多出错答案,一眼看得见;收紧只是少出答案, 而少出的那些在命中率里表现为"未命中",混在几百条数据问题里毫不起眼。 与第七条同一个形状:存在量词看不见的东西,都要另配一条计数判据。

出图这一层的硬性质(omgkit-depict)

cargo run -p omgkit-depict --release --example audit -- harness/corpus/large.smi

判据只守得住可判定的性质,守不住"这张图好不好看"。可判定的这六条要对全量 语料跑,不抽样 —— 单元判据里的分子是手挑的,而手挑时心里已经有一个模型,挑出来 的正好是那个模型覆盖得到的。实测吃过两次亏:"环内双键"那条列了八个分子全是单环 或邻稠的,桥环共用键整类漏了出去;"楔形窄端"那条列的三个分子都不含 P(V) 中心, 于是"楔形落到双键上被静默丢弃"漏了出去。

性质 前提
写法无关:换一种 SMILES 写法,画出来的图元一模一样
原子不重合:两个原子不许画在同一点上
键角不过窄:度数 ≤3 的原子处,键角不许小于 90°(三元环内角除外,见下) 布局没退化
环内双键:环上双键的两条线都落在某个含它的环里 布局没退化
楔形落地:Depiction 里记了几个楔形,画布上就有几个
楔形可读:画出楔形的中心,反读回来就是它该有的构型
键长全等:所有键画出来一样长 布局没退化
不出画布:每个图元都在画布里
线端不压字:一根键画出来的每条线,端点都停在它自己两端标签的字形盒外 两端标签塞得下

带"布局没退化"前提的两条,是因为退化的坐标本身就不成形状 —— 那时要求图画得对 没有意义,而退化这件事已经报在 Depiction::degraded 里了

「键角不过窄」和「键长全等」在三元环上互相矛盾

这一条曾经把三元环的内角也算进去,于是全量语料 181 处违例里 156 处正好是 60.0°。逐条核过(rdkit 判"那两个邻居彼此成键"),全是环丙烷 / 环氧 / 氮丙啶 的内角:

三元环内角 60.0° 156
79.1°,两邻居不成键 16
30.0°,两邻居不成键 5
60.0°,两邻居不成键 4

那 156 处不是画错了,是两条判据自相矛盾:三元环只有三根键,「键长全等」逼出 等边三角形,内角必然恰好 60°。含三元环的分子无论怎么摆都过不了「键角不过 窄」,除非先违反「键长全等」。两条不能同时满足,而键长全等是画图规范定死的, 所以让路的是这一条。

判别写成"a 的两个邻居彼此成键"—— 那等价于 a-i-j 构成三元环,不需要跑一次 SSSR。四元环不受影响:正方形内角 90°,在 FLOOR = 89° 之上。

改完 181 → 25,其余各档一个数没动(判据不参与画图)。剩下的 25 处是真违例, 不再被那 156 处埋着。

判据第二次差点自己空过:搅拌器十分之一是恒等

"写法无关"靠换一种 SMILES 写法再画一遍来验。换写法的办法是给写出器一个 优先序,先前那个优先序是拿乘法哈希凑的:

(0..n).map(|i| (i * 2_654_435_761 * k) % (n * 7 + 13))

它在很多 n 上根本不是置换 —— 一堆原子挤到同一个优先级,排序退化成恒等, 改写出来的 SMILES 与原文一字不差。全量语料实测:

改写后 SMILES 与原文完全相同 2874 / 26493 = 10.85%
优先序里的不动点 18.28%(均匀置换应当约 1/n)

苯写三遍都是 c1ccccc1,乙醇写三遍都是 CCO —— 本 crate 的头号契约,是被 一个九分之一什么都不做的搅拌器在验的。

换成 splitmix64 + Fisher–Yates 的真置换,并且每种写法最多试 8 个种子直到 规范标号确实变了(canonical_ranks 是真正的规范标号,改写后逐位相同就说明 存储序压根没动):

旧乘法哈希 真置换
查出来的真违例 137 257
一次都没比成的 case 1156(6.5%) 2

一多半的违例先前是看不见的。

顺带查出这个判据自己的一处表述缺陷:"没查成"先前记进了违例列。于是换一个更差 的搅拌器,违例数反而从 259 涨到 1293 —— 涨的那 1156 全是查不动的 case。 数字越大看着越像在认真查,实际恰恰相反。 现在拆成三行:

「查到」列的含义
写法无关 至少比成过一次的 case;「违例」列才是真缺陷
写法无关·比满 凑够 5 次真比较的 case
写法无关·没查成 一次都没比成的 case(苯那种太对称的分子)

判据本身也加了自检(check_the_shuffler,在 main 开头跑,不放 #[cfg(test)] —— cargo test 默认不跑 example 的测试,写那儿等于没写): 搅出来必须是 0..n 的置换,且 64 个种子里恒等不许超过 2 个。变异验证过:把乘法 哈希塞回去,n=2 seed=0:搅出来的根本不是一个置换 当场炸。

下面几节里 129 / 125 / 134 这些历史数字,全部是用这个坏搅拌器量的, 只反映当时能看见的那部分。它们记的是各次改动之间的相对变化,那个用途仍然 成立;但不要拿它们当写法无关的绝对水平。当前的绝对水平见「当前结果」。

桥环骨架的预存坐标表:键交叉少了 58%

桥环、笼状体系在平面上没有好解,只能退化到弹簧松弛。而松弛是局部下降 —— 现在的 5 个初值本身就常常给出自交的解。实测最常见的 8 个骨架:

骨架 现在 两万次带扰动的搜索
C1C2CCC1CC2(双环[2.2.2]辛烷) 自交 1 自交 0
C1C2CC3CC1CC(C2)C3(金刚烷) 自交 1 自交 0
C1C2CCC1C1CCCC12 自交 1 自交 0
C1C2CCC1CCC2 自交 1 自交 0
C1C2CC1CCC2 自交 1 自交 0

连金刚烷都画成自交的。而这类骨架极其集中:全量语料 177 处退化只有 44 种 骨架,top-20 覆盖 84.2%、top-50 覆盖 97.7%。

坐标不是手画的。 rings.rsregenerate_templates 对每个骨架跑两万次 带扰动的多起点松弛,按现成的 Quality(自交数、最大键长偏差、量化坐标) 挑最好的 —— 判优口径与运行时一模一样,只是搜得久得多。这张表就是一次昂贵 搜索的缓存,不是另一套标准。 生成脚本与产物一起进版本库,与 gen_elements.py 生成 element_data.rs 同一个路子。

指纹是环系骨架的规范 SMILES(取代基剥掉、原子一律当碳、键级一律当单键 —— 模板管形状不管化学);坐标按骨架自己的规范秩存取,两处都与存储序无关。

收益与代价都要说:

改前 改后
有键交叉 278(1.6%) 116(0.7%)
其中退化布局内的 230 70
写法无关违例 229 212(−17)
有骨架原子被摆成 180° 148 224(+76)
干净 / 未解冲突 16181 / 1136 一个不动

代价是那 +76 处共线:模板解按 Quality 挑,而那个口径不管共线。拿 162 处 键交叉换 76 处共线是划算的(共线由渲染补符号兜底,交叉没救),但这笔账要摆着, 不能只报好消息。

表从 top-20 扩到 top-50(覆盖 97.7%)之后再降一截,而且是净赚的:

top-24 top-50
有键交叉 116 102(−14)
写法无关违例 212 211
骨架原子 180° 224 234(+10)
干净 / 未解冲突 / 重合 16181 / 1136 / 78 一个不动

还试过把"共线的二度原子数"插进 Quality 的第二档(想治那 224 处 180°)—— 亏了,已否掉。数字与理由记在 rings.rsQuality 文档里:骨架上同样 0 自交 的几个解,挂上取代基之后交叉数并不一样,第二档挑谁对整分子的交叉不可预测; 实测 +34 处交叉换 −152 处共线,而交叉可能让人读错、共线补了符号并不误导。

个例上有得有失,要说清:金刚烷的键交叉 1 → 0;樟脑的交叉没变,未解冲突却 0 → 1。模板优化的是光骨架,它不知道取代基 —— 骨架单独最优,挂上取代基未必 最优。全量上未解冲突持平(1136 → 1136)正是这么来的:有的变好有的变坏,而键 交叉是净赚的。

命中模板仍然报 degraded 模板给的键长并不严格全等,它只是换了一个更好的 退化解,不是把问题解决了 —— 判据 a_templated_system_still_reports_itself_degraded 守着这一条。

三个变异:

变异 结果
把表清空 the_table_is_actually_used 红,交叉回涨到 276
坐标按存储位置存取而非骨架规范秩 ✅ 红 —— 但红的不是写法无关那条:那样读到的是错的坐标,给出"错但一致"的形状,所以抓它的是两条形状判据

度 4 置换:翻转够不着的那一半

绕键 C–D 翻转,镜像的是远侧那一整块,轴是 C–D 这条线 —— 它对调的是关于 这条轴对称的那一对邻居。垂直于轴的那一对怎么翻都换不过来。 度 3 时绕第三 根键翻正好能对调另两根,所以这个算子只在度 4 的结点上有意义。

靶子先量:在 omgkit 自己的 2856 组未解冲突原子对上,最短路上

占比
有可翻转的键(现有算子够得着) 55.3%
有度 4 且完全不在环上的原子 57.2%

(审核按 RDKit 的布局、0.75 键长阈值估的是 20.1%。口径不同 —— 那是在另一个 引擎的坐标上量的另一批碰撞对,不能直接拿来当 omgkit 的靶子。)

不照抄 findBondsPairsToPermuteDeg4 它用 fabs(dp) < 1e-3 判两根键是否 垂直,硬假设度 4 结点画成 90° 十字;而 omgkit 的 allocate 在已占方向 ≥2 时是劈最大空隙、free_direction 还会按 ±30° 躲让,十字没有保证。照抄那个点积 判据会静默掉进 else 分支,返回的恰好是本来就能被翻转达到的那一对,算子 等于白做。改成六对全枚举,让 better() 自己判优。

还有一点要说清楚:permuteBonds 在整个 RDKit 里只有一个调用者 —— randomSampleFlipsAndPermutations,而它默认关着(nSamples = 0,Python 的 permuteDeg4Nodes=False)。所以这不是移植,驱动器是自己写的。

收益(全量 17662 个分子×规范):

改前 改后
干净 16156(91.5%) 16181(91.6%)
有未解冲突 1161 1136(−25)
有键交叉 278 276(−2)
写法无关 / 键长全等 229 / 0 229 / 0(未退)

比螺环翻转(+6)大四倍。

两个变异,结果一个在意料内一个不在:

变异 预期 实际
候选序改回存储序 写法无关退 229 → 266(+37) —— 规范秩定序在这个算子上真正吃劲
去掉"中心不在环上" 撕裂判据红 仍绿 —— 真正兜底的是显式那句"两块必须不相交",这个守卫只是便宜的前置过滤

第二条已经把注释改成实情:留着它是因为便宜,不是因为少了它会出错。

far_side 跨分量取补集:判据一上来就照出来了

relieve 跑在所有分量合并之后pos 上(分量之间也可能撞上)。far_side 要取"原子更少的那一侧",而它算另一侧的办法是pos 取补集 —— 那个补集 跨过分量边界。翻一根键就会把另一个不相干的片段整个镜像走。

先前查过它的可观测后果:280 个多分量的图,用分离轴定理判,没有一个分量 互相穿插。结论当时是"潜在陷阱,不是活缺陷" —— 这种翻转必然制造碰撞, better() 把它挡掉了。

但把它写成判据之后,第一个分子就红了: CC(=O)Oc1ccccc1C(=O)O.CC(C)(C)c1ccccc1 翻键 0,far_side 返回的集合横跨 分量 0 与 1。函数层面本来就是错的,只是错误结果从没被接受过。

改成"从另一端出发、同样不跨这根键能走到的那些原子"。收益出乎意料:

改前 改后
写法无关违例 257 229(−28)
干净 / 未解冲突 / 交叉 16156 / 1161 / 278 一个不动

这是本轮对头号契约最大的一次改进。 根子是旧代码拿全局 pos.len() 判 "哪一侧更少" —— 多分量分子里这个总数把不相干的片段也算了进去,于是"翻哪一侧" 随分子的其它部分而变。

教训记一笔:"可观测后果没出现"不等于"函数是对的"。前一次只量了图,这次 把契约直接写成判据,才照出来。

试过"键角越接近 120° 越好",亏了 —— 记一笔

避让排序现在是"同样偏离一档时先试角度更宽的那一侧"。它有个副作用:180° 正是 最宽的,于是新键与已有的键连成一条直线,那个二度原子在图上根本看不见。全量 148 张图(0.8%)因此出现「骨架原子被摆成 180°」。

于是试着把排序键换成 |最窄夹角 − 理想值|,让 60° 与 180° 同等地躲。全量语料 上是亏的:

越宽越好(保留) 越接近 120°(否掉)
骨架原子 180° 148 140(−8)
键角不过窄 288 356(+68)
未解冲突 1161 1199(+38)
写法无关 257 260(+3)
有键交叉 278 275(−3)
干净 91.5% 91.3%

拿 8 处共线换 68 处窄角加 38 处冲突,不划算。"更宽"同时也意味着"离别的原子 更远",这才是它压住碰撞的原因 —— 两个目标在这里是耦合的,不能只盯着角度看。 那 148 处由渲染那边补符号兜底,并如实记在质量分档里。

稠合处的共用双键:芳香性提为第一档

先前的规则是"进双键最多的那个环"。它是芳香性的代理 —— kekulé 化之后芳环的 交替双键通常就是最多的,所以两条规则几乎总是一致:全量语料 1092 根稠合处 的共用双键里只有 3 根分歧。

代理会在"非芳环的双键比隔壁芳环还多"时给出错的答案,而芳香标记是直接的,所以 把它提为第一档,双键数退为第二档,小环、形心继续兜底。绝不能照抄 RDKit 的 "第一个芳香性全匹配的环" —— 那个"第一个"按环的存储序,会把写法依赖带进来。

判据不是挑的分子,是扫出来的那三根:a_fused_double_bond_goes_into_the_aromatic_ring 只查"一芳一不芳"的共用键,并且要求至少查到一根(否则空过)。变异验证: 去掉芳香这一档,它在 [H]/N=c/1\[nH]c-2c(s1)CSc3c2cccc3 的键 4–5 上立刻红。

全量:写法无关 257 → 257,其余一项不动。

双键内侧线两端按角平分线斜切,不按固定比例缩

内侧线先前两端各缩固定的 12%。那个数只在某一个夹角下恰好对:苯环上正确的 缩进量是 s / (2 × 边心距) = 0.18 / (2 × 0.866) = 10.4%,差 1.6 个百分点, 端点因此偏离顶点角平分线 0.2 pt,接头处露白。夹角越偏离 120° 差得越多。

改成与 RDKit 的 DrawMol::doubleBondEnd 同一个做法:取偏移那一侧的一个邻居, 角平分线是两个方向的单位向量之和,内侧线是过 e+n、方向 e→o 的直线,两者 求交。规则多边形上这恰好落在"环心 → 顶点"那条射线上,于是有闭式解可判。

这一条是先写判据、确认它红、再动手的。 判据 a_ring_double_bond_inner_line_meets_the_neighbouring_bonds 要求苯环内侧线的 端点落在顶点角平分线上(容差 1e-6 pt);在旧代码上它报"偏离 0.2005 pt", 改完转绿。先前那次改环内双键的画法没有判据接得住,这次补上了。

挑哪个邻居不能看存储下标 —— 沾上就把写法依赖带进来了。按量化后的投影值排, 量化坐标兜底平局;坐标此时已规范化过。全量语料上写法无关 257 → 257,一处没退。

消冲突加了螺环翻转:补能力空洞,但收益要如实说

rotatable 排除环上的键(翻了会把环撕开),而螺原子两侧全是环上的键 —— 螺环两侧的相对朝向先前根本动不了。靶子是量过的:

基线 有交叉的图里 有未解冲突的图里
含螺原子 1.2% 13.7%(富集 11.7×) 8.6%

绕"螺原子 ↔ 它在目标环里两个邻居的中点"这条线,把那个环连同挂在它上面的 一切镜像过去。RDKit 的 flipAboutSpiroCenter 是同一个算子,但两处平局都 不能照抄:它固定翻 rings[0]、从 atomNeighbors 的第一个取要翻哪一侧, 两处都是存储序。这里都按规范秩定。

收益如实说:全量语料上它让 6 张图从"有未解冲突"变干净(91.4% → 91.5%), 键交叉一处没消。 做它是因为它便宜、不碰任何契约(等距变换,键长键角全保), 不是因为它大。写法无关 257 → 257,一处没退;三次运行逐字节一致。

一处我先前想岔了、被变异验证纠正过来的:我以为镜像轴选错会改键长,还写了 "实测某分子的键 1–2 被拉到 1.155"。不对。 绕任何一条过螺原子的直线反射, 都保持每个点到螺原子的距离,而跨越边界的键只有 S–N1、S–N2 两根 —— 所以 这个算子无论轴选哪条都保键长。那个 1.155 是旧判据(要求每根键都等于 1) 在退化布局上本来就会红,与螺环翻转无关。

轴选中点买到的是另一件事:|S−N1| = |S−N2| 时它恰好是角平分线,反射把 N1 与 N2 精确对调,环落在自己的镜像位置上,是一次对称操作而非把环转到任意 朝向。相应地,判据的分工也变了:

判据 抓得到 抓不到
a_spiro_flip_is_exactly_isometric_and_actually_fires relieve 里塞开角/缩键;算子一次都不触发 轴选错(键长怎么都不变)
a_spiro_flip_does_not_depend_on_how_the_molecule_was_written 轴选错(变异:轴改指向 N1,立刻红)

还有一处要交代:把候选序改回存储序这个变异,在现有这批分子上没有咬中。 按规范秩定序是按原则做的,不是被红判据逼出来的。

两根键连成直线的原子,图上看不见

丙二烯 CH₃CH=C=CHCH₃ 的中心碳是 sp、键角 180°。omgkit 的几何是对的 (an_sp_atom_is_drawn_straight 守着),但那个碳画出来什么都没有 —— 顶点处没有拐角,两根双键的第二条线又都偏在同一侧,整张图读起来是顺式二烯:

omgkit(旧)                RDKit
  ___________               ___    ___
 /  ---  ---  \            /   \\ C //   \
/              \          /      \\ //     \

RDKit 的注释原话是 "allenes need a C"。它的判据是几何的 (isLinearAtom):二度、两根键键级相同、方向点积 < −0.95(约 162°)。 键级要相同这一条有讲究 —— R—C≡C—R 的炔碳两侧一单一叁,三条平行线本来就 把它标出来了,不用再画符号。

照抄这个口径,并且两根双键各自跨轴对称画。两件事缺一不可:只画符号不对称, 两条同侧短线仍然像顺式;只对称不画符号,中心碳照样看不见。判据 a_collinear_atom_is_drawn_instead_of_vanishing 同时守这两条,变异验证过, 两个变异咬中的是不同的断言

顺带量出一件更要紧的事。 全量语料 300 处共线里,真正的累积双键只有 42 处:

个数
骨架碳,两根单键碰巧共线 154
骨架碳,其它键级 40
本来就有标签 64
骨架碳,累积双键(该补) 42

那 154 处的坐标本身就不对:sp3 碳该是 120°,是取代基避让一档一档挪出来的 (free_direction 最多挪五档 × 30°,120+60 就是 180)。补上符号图能读了, 布局的毛病却被盖住了 —— 所以单独计一档「有骨架原子被摆成 180°」,全量 148 例(0.8%)。这一档不是渲染的问题,是布局的问题,记在这里等着修。

挂在环上的臂朝哪边伸:不能盲选锯齿的符号

阿司匹林画出来乙酰基整条臂折回去贴着苯环。量它的键角:

C1 处 0-1-2:   90.0°   ← CH₃–C=O,该 120°
C1 处 0-1-3:  120.0°
C1 处 2-1-3:  150.0°   ← O=C–O,该 120°

原子 1 是乙酰基那个 sp² 碳,三根键该各 120°,却是 90/120/150 —— 正好是 120∓30,是 free_direction 按 30° 一档挪出来的。而它之所以要躲,是因为整条臂 朝环卷了回去,羰基氧的理想位置撞上了环。

根子在 allocate:只有一个已占方向时,±理想角两侧都合法,而它按锯齿的符号 盲选 —— 那个符号不看旁边有没有东西。挑错一侧,臂就卷回来。

改成两侧都算一遍拥挤度(新位置到每个已放好原子的平方反比之和,与 RDKit 的 density 同一个口径),挑空的那边;分不出高下时保持锯齿 —— 直链两侧一样空, 锯齿不受影响。全量:

改前 改后
键角不过窄 291 180(−111)
有键交叉 102 78(−24)
写法无关违例 211 201(−10;当时比 5 种写法)
干净 16181 16202
有未解冲突 1136 1119
原子不重合 78 77
不出画布 / 环内双键 / 键长全等 / 楔形 0 0

没有一项变差。

两条弯路,都记下来:

一、试过在 free_direction 里加"对侧那个同样理想的位置"(把理想方向关于已占 方向镜像)。变异验证说它不吃劲 —— 去掉之后角度判据照样绿,而全量上去掉它 反而更好(窄角 209 → 180、交叉 83 → 78)。原因是"对侧那个理想位置"通常正被 兄弟取代基占着;真正管用的是上游整条臂换边。

二、还试过一个更保守的换边判据:"只在这一侧确实会被迫歪角时才换"。它救不了 阿司匹林的 ACS 那张 —— 羰基氧的理想位置在放它的那一刻还没被占,是后面的原子 挤过来的。拥挤度看的是整体,才拦得住。

判据 an_arm_hanging_off_a_ring_keeps_its_ideal_angles每个原子自己的理想角 的整数倍判(度 3 只许 120,度 4 许 90 与 180,sp 只许 180)。不能拿一张 白名单 120/180/90 —— 那样度 3 原子上的 90° 会被放过,而那正是要抓的毛病。 变异验证:去掉换边,它当场报 0-1-2 的夹角是 90.0°

refine::the_aspirin_overlap_is_gone 里那句"至少翻转过一根键"也跟着改了:修法 搬到上游之后 relieve 无事可做,那句成了假前提。换成布局阶段就已经没有重合 —— 更贴近现在的事实,而且上游那个改动一去掉它立刻红。

键要接到元素符号上,不是接到整串的中心

标签是 text-anchor="middle" 摆的,整串居中在原子位置上。但键连的是元素 符号那个原子,不是整串。 实测 ACS 下:

全宽
OH 1.042 个键长
O 单独 0.540 个键长

O 的中心离原子位置 0.251 个键长,整整四分之一根键。

h_side 把氢甩到键来向的反面,横着的键因此看着还对(HO– 的 O 在右,键从 右边来);竖直的键就露馅 —— 它落在盒的上/下边缘、横向居中,正好落在 O 和 H 中间。glucose 环上下方那几个 OH 就是这样。

改法:Label 带一个 dx,整串朝一侧挪这么多,让符号回到原子上。代价是 盒心不再在原子上,裁键、判碰撞都要跟着算:

  • render::trimbox_reach 从"居中盒的射线长度"改成"从盒内一点出盒", 而且两端要各算各的 —— 盒不再左右对称,一个值两边通用不成立了。
  • refine::radii 暂时没改,见下。

全量语料:干净率、未解冲突、键交叉、写法无关一项没动——这一改在硬指标上 是免费的,买到的纯是观感。

判据 the_element_symbol_sits_on_the_atom_not_on_the_string_centreruns 独立重算符号该落在哪,不复用 build 里的中间量。变异验证:把 dx 归零, 它当场报 元素符号 O 的中心离原子 -0.2507 个键长 —— 与上表算出来的一致。

一处没做完的,要说清。 refine::radii 仍按"盒心在原子上"取外接圆,于是 长的那一侧被低估约 dx、短的那一侧被高估同样多。试过改成"原子到盒最远那个 角":各向同性地取最大,长边诚实了、短边跟着过估,干净率 91.6% → 89.3%、 未解冲突 +401,亏的。正解是碰撞判定直接用偏心的盒对盒而不是一个各向 同性的半径 —— 那要改 remaining 的判据本身,记在账上。

键线在标签外停得太远:按外接圆切,不按盒边切

键线要在原子标签外停住,免得线划过字。先前切的是标签盒的外接圆 (half_w.hypot(half_h)):

// 旧:圆一定包住盒,所以线绝不会压到字 —— 代价是在盒窄的方向上停得太远
l.half_w.hypot(l.half_h) + style.margin()

圆包住盒,安全,但在盒窄的那个方向上白白空出一大段。竖直方向去接一个横向 宽的标签,差的正是 hypot(w,h) − h。全量语料 129330 个带标签的键端:

平均白切 0.075 个键长
白切超过 0.1 个键长的 28259 个(21.9%)
最糟 0.39 个键长 —— [NH2+],快四成键长的空白

改成沿键的方向与轴对齐盒求交(标签是 text-anchor="middle" 摆的,盒以原子为 心)。顺带把"两端标签加起来比一根键还长"的键从 2.77% 压到 1.26% (全量 283604 根键)。

这里需要两条判据,少一条都会被糊弄过去:

判据 守什么 只有它时怎么被绕过
a_bond_stops_at_the_glyphs_not_at_the_box_around_the_whole_string 切得够紧 trim 改成一律不切,它更好看,而字全被划掉
no_drawn_line_runs_across_an_atom_label 切得够远 换回外接圆,它照样绿

变异验证过,两条正交:换回外接圆只有前一条红;trim 一律不切两条都红。

剩下 1.26% 的键是真塞不下 —— ACS 规范下标签占 0.69 个键长,O⁻—N⁺ 两端要 1.375 个键长的净空。翻转动不了它(键长是定死的),所以不记违例,单独计一档 「有标签在键上塞不下」

逐字形的盒后来做了,见下面「逐字形标签盒:塞不下 1146 → 859」。那一节也把 这里的两条判据换成了三条,名字跟着改了。

这一档当时报的是 1818 例(10.3%),那个数多报了:判据手抄了一份 居中盒的算法,而实现早已改成偏心盒。按实现的口径重算是 1271 例(7.2%), 见下面「判据自己抄了一份实现」。

双键的第二条线没跟着让开字

咖啡因咪唑环上那根 C=N,ACS 下画出来是这样:主线为了让开 N 停在离原子中心 5.56pt,内侧线却停在 3.20pt —— 比主线还伸得靠前 2.4pt,一头扎进字里, 看上去像一个指着 N 的楔子。CD 下同一处也伸过了头,只是字相对键短,没扎进去。

trim 只管键轴那条线。内侧线的端点是 mitre_end 算的角平分线交点,而 角平分线是从原子中心量的 —— 它压根不知道主线已经缩回去了。两端都不带标签 时这个交点是对的(规则多边形上它正好落在"环心 → 顶点"那条射线上);端原子 一带标签,两条线的基准就错开了。

两处都要改:

改什么 治什么
端原子有标签就不斜切 内侧线端点 = 主线端点 + 法向偏移 内侧线越过主线,扎进字里
escape_boxes 把横向平移出来的线端点沿线推出盒 + margin 的标签:主线从上方出盒只让开半高,再横着挪 0.18 个键长仍在半宽之内

第一条与 RDKit 是同一条规则 —— DrawMol::doubleBondEndtrunc 参数取 !atomLabels_[at],注释写得很直白:"if there's an atom label, we don't need to step the bond end back because both ends are shortened to accommodate the letters"。量了 RDKit 画出来的咖啡因核对:C8=N9 在不带标签的 C8 那头内缩 7.46px(五元环 108°,闭式解 offset/tan(54°) = 10.3/1.376 = 7.48 ✓),在 标签的 N9 那头内缩 0

escape_boxes 只作用在从主线横向平移出来的那些线上。主线不走这一步 —— 它归 trim,连"两端塞不下时按比例压缩"那档兜底一起。一条线不能有两个主人: 压缩兜底是故意把端点留在盒里的,再来一道不知情的推挤只会把它推回去。

新判据 线端不压字,全量:

改前 改后
线端不压字 3044(17.2%) 0(比过 17576 次)
干净 / 未解冲突 / 键交叉 / 写法无关 / 键角 / 键长 一项没动

判据的范围要说清楚:只查这根键自己的两端。 别的键的线压到这个标签上是 布局没摆开,已经报在 未解冲突有键交叉原子不重合 里了。第一版 没划这条界,报出来的头几例全是两个羧基挤到一起 —— C=O 的线压在另一个羧基的 O 上,与双键怎么画毫无关系。

单元判据补了两条,变异验证过,彼此正交:

判据 去掉哪一处会红
a_double_bond_inner_line_does_not_reach_past_the_main_line_into_a_label 不斜切那一处:报「咖啡因键 2 的内侧线越过主线伸向带标签的原子 3:主线停在 8.85,内侧线停在 9.80」
no_drawn_line_runs_across_an_atom_label + c1cc2ccc[n+]3c2c(c1)SCC3 escape_boxes:报「原子 9 的标签 HC 被一条线的端点压在里面」

咖啡因在后一条上不吃劲 —— 内侧线压到 N 上时,只差 0.14pt 就够不着那条 判据给线宽留的余量。所以"越过主线"这件事得单独有一条判据量,不能指望"压没压 到字"顺带抓住:两条量的不是一回事,合成一条就会漏。

端基双键一律对称:这条规则过宽,丙烯的顶点合不拢

丙烯 CH₂=CH–CH₃,中间那个碳上还挂着一根单键,而单键是收在原子中心的offset_dir 里"一端是端基就对称跨轴画"这条规则一开,两条双键线一条在轴上方 1.3pt、一条在下方,谁也不从那个顶点出发 —— 单键的尖头戳出来,旁边留一个 豁口。放大看一眼就明白,不是分辨率问题。

改前 改后
主线起点 (19.82, 9.12),离顶点 1.3pt (20.47, 8.00),就是顶点
第二条线 (21.12, 6.88),另一侧 偏向甲基那侧,顶点处斜切 1.50pt

斜切量有闭式解可核:顶点 120°,spacing/tan(60°) = 2.59/1.732 = 1.496

注释里对 RDKit 的引述先前是错的。 原话是"RDKit 的 calcDoubleBondLinesisLinearAtom 与端基放在同一个分支里,口径一致"。真正的条件是

isLinearAtom(at1) || isLinearAtom(at2) || (at1->getDegree() == 1 && at2->getDegree() == 1)

—— 端基进对称支的前提是两端都是端基。单端端基另走 doubleBondTerminal, 在那里再分三档,只有两档对称。按内侧原子分类数全量语料的 9117 根端基双键:

内侧原子 根数 RDKit 我们(改前)
两端都是端基 / 共线 37 对称 对称 ✓
度 2、带标签 100 对称 对称 ✓
度 >2(异丁烯、丙酮) 8576 对称 对称 ✓
度 2、无标签(丙烯这档) 404(374 个分子) 不对称 对称 ✗

顺带纠一句:旧注释说"醛是对称的通例"也不对 —— RDKit 画的乙醛 CC=O 同样是 一条线走键轴。而"偏一边会让 C=O 看着像挂在碳上"这个担心正好反了:真正让两条 线都脱开顶点的,是对称那种画法。

改法是按 RDKit 的三档分,把该对称的三档留在早退里,只放丙烯那一档下去投票。

第一版没这么做,只把早退条件从 || 收成 && 就完事了 —— 走过了头。 我当时的理由是"度 2 的内侧原子只有一个别的邻居,票数必然 ±1,自动偏向它; 度 >2 的两个邻居分居两侧,票数 0,自动仍旧对称"。两句各错一半,复审实测 多改了 216 根键(每套规范),方向与 RDKit、与改前、与我自己上面那张表都相反:

被误伤的 根数(每套规范) 为什么
内侧带标签(亚硝基 R–N=O、亚胺 CH₂=N–R) 100 / 95 分子 "只有一个别的邻居票数必然 ±1"对它同样成立 —— 而这一档那一头的键全停在字盒外,压根没有顶点要合,偏一边只丢了对称性,换来 = 挂在字母底边上
内侧还有别的分叉(砜、硝酸酯) 116 / 68 分子 "两个邻居分居两侧"只在度恰为 3 时成立。度 ≥4 有三个别的邻居,票数是奇数项之和,抵消不掉 —— 实测砜的两根 S=O 双双朝对方倾斜,四条线挤在中间

"全量指标逐字节相同"当时没能拦住它 —— 审计里没有任何一条性质在量"对称 还是不对称",指标不变证明不了改动范围。真要量范围得直接数:改完之后按分档统计 (每档 × 两套规范),内侧度 2 无标签 808 根全不对称、其余四档 17426 根全对称, 与设计一致。

末端那头另改成与主线齐头(先前缩 12%):端基上没有第二根键可接,缩进去只 看着短一截。RDKit 在那一头也是纯法向平移。

全量 17662:每一项指标与改前逐字节相同,这一改只动观感 —— 但如上,这句话是 结果,不是范围的证据

原来那条 a_terminal_double_bond_is_drawn_symmetric 把两件事混在一起,拆开:

判据 管什么
a_double_bond_with_no_inner_side_is_drawn_symmetric O=C=OC=CCC(C)=CCC(C)=O,外加 CN=O(内侧带标签)与一个砜(内侧度 4)
a_terminal_double_bond_closes_the_joint_at_the_inner_atom 顶点合得拢、第二条偏向内侧原子的另一根键那侧、末端齐头、内侧那头斜切到角平分线上

两处判据是被复审逼出来的,原样记下:

一、CC(C)=CCC(C)=O 这两个内侧度 3 的分子挡不住"把早退整个删掉"这个 改法 —— 它们的两个邻居分居两侧、投票碰巧抵消,照样对称,绿得没有道理。 补 CN=O 和那个砜进去才有牙。

二、"顶点合得拢"这条只等价于"没画成对称":内侧原子不带标签,trim 不动 端点,主线必然从原子中心出发。我拿"改回一律对称""末端缩 12%"两个变异验过它红, 但那两个变异都打在同一个等价面上 —— 复审做了我没做的第三个:把内侧那头的斜切 整个去掉,四条断言全绿,而画出来第二条线穿过相邻的单键伸出去 1.30pt (约 0.09 个键长)。链上这一档的斜切当时没有任何判据在核,那句"斜切量有闭式解 2.59/tan60° = 1.496"是写在文档里、没人验的。

补的第四条断言直接量落点:第二条线的内侧端点应落在角平分线上、距内侧原子 spacing / sin(θ/2)。去掉斜切它当场红。

教训:变异验证要挑打在不同面上的变异。 同一个等价面上做三个,不比做一个多 证明什么。

楔形:自己验自己是空过的,21 个中心画成了对映体

stereo::assign_wedges 是"试 Up/Down,取反读回来对的那一个"构造出来的, 而反读用的就是 read_chirality —— 两者共谋,拿它们的往返去检验是空过的。 函数自己的文档早写着这一点,但一直没有第二个判官。

补上了:examples/dump_molblock.rs 把画出来的坐标 + 楔形导成 V2000 molblock (1 = 实楔、6 = 虚楔,窄端写成键的第一个原子), harness/check_wedge_readback.py 交给 RDKit 从 2D + 楔形指派手性,与输入逐个 中心比 CIP 码。问的是另一个问题:别人照着这张图读,读出来是不是同一个分子。

按中心比,不按分子比。 按分子太粗:一个分子里可能既有如实报了 unwedged 的中心、又有没报却画错的中心,按分子会把后者藏在前者后面。

全量语料基线:

中心数
构型一致 459
不一致,但已报 unwedged(如实说了画不出) 18
不一致,且没报 21

这 21 个是画出来的就是错的,而且说自己对。 拓扑完全正确、线条毫无毛病, 只有分子是镜像的 —— 这正是自己验自己永远发现不了的那一类。

根因:中心落在三个邻居围出的三角形外面

一个中心画三根键、其中一根带楔形时,读者的推断是"带楔形的那根出/入平面, 另两根在平面里,隐式氢在楔形的反面"。这条推断的前提是三个邻居把中心 围住。三个邻居若全挤在同一侧(最大空隙 > 180°),第四个配体本该指进那个空 扇区,而不是"投影到中心上" —— 两种读法不再等价,不同实现读出对映体。

量出来的:

三邻居的立体中心(全量 420 个) 个数 其中退化布局
正常(中心在三角形内) 375 33
挤在同一侧(> 180°) 45 42

那 21 处错读全部落在"挤在同一侧"里,张角只有 120–137°;而 45 个里 42 个出在 桥环退化布局上,3 个出在正常布局上 —— 所以判据要按几何写,不能按"退化"写

这件事的分量

它比"楔形画在环键上"严重:后者是该画的没画(画出来的仍是对的),这个是 画出来的是错的,而且说自己对。第二条契约就是"画不好要如实报出来"。

修法一:氢摆错了地方,不是"读法有歧义"

一开始我以为这是约定之争(两种读法都讲得通,只能如实报"读不出")。不是。

四面体的四个键方向之和为零 ⇒ 它们的 2D 投影之和也为零。三个投影全落在半 平面里,第四个必然落在对面的空扇区 —— 不可能"投影到中心上"。先前摆的 [0, 0, −z] 只在"三个邻居把中心围住"时成立,是模型用超了范围,不是歧义。

改成按同一条恒等式摆:面内取三个邻居单位方向之和的负值。均匀分布时这个和 恰为零,退化成先前的做法;挤在一侧时它指进空扇区。一个公式管两种情形,中间连续。

判据 a_cramped_stereocentre_is_not_read_as_if_the_h_sat_on_the_centre 的形式 是"同一个几何,氢摆中心 vs 摆空扇区,读出来必须不同" —— 只钉住这个位置 吃劲,哪个对由外部判官定。

角度是搜出来的,不是随手写的。 头一版写 [0, 60, 120](确实挤在一侧), 两个模型读出来一样,判据当场空过。穷举 10° 栅格上全部"挤在一侧且三重积够 大"的组合,3484 组能区分,取了空隙 240° 的一组。

修法二:两根键几乎共线时,谁也定不出手性

改完还剩 1 处。查到底是一个笼状分子的中心,两根键相差 184.4°

去读了 RDKit 的源码(Chirality.cpp:3448)—— 它的判据根本不摆氢:直接算 三个画出来的邻居的三重积 v1·(v2×v3),|vol| ≤ ZERO_VOLUME_TOL = 0.1 就判 CHI_UNSPECIFIED。手算那个中心:两根近共线的键叉出来的 z 分量只有 −0.0905, 低于 0.1,所以 RDKit 说"未指定"。真正的退化不是"挤在一侧",是三根键里有两根 几乎共线 —— 那时楔形定不出手性,与氢摆在哪无关。

照抄了这个口径和这个常数。照抄常数是有理由的:我们导出的 molblock 就是交给 它读的,两边用同一把尺,"别人读得回来"才谈得上。 全量 404 个三邻居中心里只有 1 个落在线下(0.0905),下一个是 1.24 —— 中间差 13 倍。

拒掉之后不是消音:assign_wedges 接着试下一根候选键,那个中心因此换了一根 键,RDKit 读出了正确的构型。

结果

基线 修完
构型一致 459 480
不一致,但已报 unwedged 18 18
不一致,且没报 21 0

全量审计每一项指标不变,三次运行逐字节一致。

审计里那一档改名叫 —— 有立体中心的取代基挤在一侧(50 个分子×规范):它已经 不是正确性问题了,本实现与 RDKit 读出来一致;留着是因为它仍是布局质量的 信号 —— 这样的图读者得自己想明白"第四个配体在空扇区里"。

变异验证:氢摆回中心 → 第一条红;去掉体积守卫 → 第二条红。

补显式氢:环上的楔形 158 → 18

三根键全在环上、还有一个氢的立体中心,唯一合法的楔形是 C–H —— 那个氢必须画 出来(见 hydrogens 模块的文档)。接进 generate 之后,全量语料:

楔形落在环键上 158 18(−140)
打在补出来的氢上 0 150
立体中心的取代基挤在一侧 50 2(−48)
外部判官:构型一致 494 496
外部判官:不一致且没报 0 0
干净 16202 16186(−16)
有未解冲突 1119 1135(+16)
写法无关违例 223 231(+8)
硬性质(不出画布/环内双键/键长/线端/楔形) 0 0

剩下的 18 根环上楔形:8 根无解(四根键全在环上、无氢,补不了也没有合法 楔形)、3 根是"两条忌讳冲突"那笔没做成的交易、7 根杂项。

胆固醇的 C8/C9/C14 现在画出了三个带楔形的 H,与 RDKit 一致 —— 那正是最初提出 这个问题的那张图。

下标空间是这次最容易出事的地方

被画的分子比传进来的多几个原子,而 coordswedgesunresolvedcrossingsunwedgeddegraded 的下标全部相对补完之后的编号。方案审核 把这一条列为阻断级问题,实测过越界的 unresolved 32 处、落在 C–H 上的 wedges 298 条。

按审核的建议做成 Depiction::drawn(&mol) -> Cow<MolBuilder>:没补东西时借用、 不复制,渲染与判据一律拿它。这样每条判据只要把 m 换成 d.drawn(&m) 就 自动覆盖补出来的氢,不用为氢单写一套并行逻辑。

写法无关那一条是例外,而且不能想当然。 它要把分子重写成 SMILES 再画一遍 —— 拿补完的去写,显式氢会进到 SMILES 里,改写出来的就不是同一个分子了。所以 checks 同时收 origdrawn,文档里写清楚哪条用哪个。

判官自己先漏了 149 处

接完之后判官报 149 处「画成 None」,吓了一跳 —— 查下来是 dump_molblock 还在导分子:楔形恰恰打在那根补出来的 C–H 上,而那根键根本没写进文件, 外部实现看到的是"没有立体信息"。不是画错了,是判官自己没跟上。

这与更早那次 canvas_pts 手抄 bounds 是同一类:改了实现,判据这边的取数 口径没跟着改,而错的方向恰好是"看起来很像真缺陷"。

代价要说清楚:写法无关 +8,而机理不是我先前写的那个

这是唯一一笔真代价,动的是头号契约

方案审核推测的机理是"补出来的氢把冲突数推高,refine::relieve 的贪心翻转进入 近平局区"。我照抄进了上一版文档,没验 —— 验了之后不成立。

三个实验:

问题 做法 结果
是不是 relieve 干的? relieve 整个停掉 那 6 个分子照样散(12/12)
补出来的是不是同一批氢? 比改写前后补完之后的规范 SMILES 完全一致
规范秩有并列吗? 数一遍 没有

再往管线里插分阶段指纹(按规范秩排的量化坐标),分岔出现在 piece 阶段 —— 也就是 layout_all 加分量摆放,在顺反校正和消冲突之前。而且只有两种结果, 像是某一处二选一被写法决定了。

所以那 6 个不是补氢引入的新缺陷:补出来的分子是同一个,只是编号不同; 它们落进了基线本来就有的 223 那一类。根子在布局,补 H 只是把 6 个分子挪进 了那个类。

修它就等于修基线那 223 —— 这是下一件事,而且比"+8"这个数字大得多。

三个空隙精确相等,而挑哪个由末位决定:写法无关 231 → 155

顺着上一节挖下去,把根子挖到了,而且它比"+8"大得多。

定位的过程(每一步都是先做实验再下结论):

问题 做法 结果
是消冲突干的吗 停掉 relieve 照样散
补的是同一批氢吗 比改写前后补完的规范 SMILES 一致
规范秩有并列吗 数一遍 没有
分岔在哪一阶段 管线里插按规范秩排的量化坐标指纹 piece,即布局本身
布局里哪一步 layout_all 里再插两处 环系统一致,分岔在 BFS
哪个原子 逐秩比坐标 只有一个:补出来的那个氢
它拿到了什么 place_neighbours 里打印全精度 见下
写法 A: occ = [-2.09439510239319571, -0.00000000000000067, 2.09439510239319526]
写法 B: occ = [-2.09439510239319615, -0.00000000000000067, 2.09439510239319526]
                              ^^^ 差 4.4e-16
写法 A: want = +1.047…(+60°)     写法 B: want = -1.047…(-60°)

三个已占方向恰好各差 120°(稠环上挂一个取代基就是这样),于是三个空隙在 数学上精确相等largest_gapif g > best.1 直接比浮点 —— 谁"最大"由 末位决定,而末位取决于环坐标是按什么次序算出来的。这不是布局挑错了,是根本 没在挑。

顺带纠一个我自己的误判:中途我看分阶段指纹说"环系统逐位相同",那是量化到 1e-4 的假象 —— 4.4e-16 的差在那个精度下当然看不见。

改法与本文件里别处同一个路子:空隙量化到 1e-9 再比,仍然并列时取起始角 最小的那个扇区(绝对判据,与写法无关)。全量:

写法无关违例 231 155(−76,降 33%)
干净 16186 16196(+10)
有未解冲突 1135 1123(−12)
有键交叉 1 3(+2)
外部判官:不一致且没报 0 0
硬性质五条 0 0

比补 H 之前的基线 223 还低得多。 这一处是本轮所有工作里对头号契约影响最大的 一笔 —— 而它是被"补 H 让 6 个分子变得写法相关"这条线索带出来的。

两条判据,变异各红一条:回到直接比浮点 → 平局那条红;不看空隙大小只取第一个 扇区 → "真的更大要赢"那条红(外加既有的 a_new_branch_goes_into_the_largest_free_sector)。

顺反校正镜像哪一侧,是书写痕迹说了算:写法无关 155 → 77

给剩下的 78 个写法相关分子做了一次首次分岔阶段的分布(管线各阶段插按规范秩 排的量化坐标指纹):

首次分岔阶段 分子数
1-seed(环系统布局) 3
2-bfs(链/取代基) 32
4-cistrans(顺反校正) 40
5-relieve 1
坐标全程一致 2

fix_cis_trans 掰一根顺反键的做法是把一侧的子树整体镜像两次镜像不对易 —— 多根顺反键时,先掰哪根、掰哪一侧决定最终的几何。先前一是按键的存储下标 遍历,二是一律镜像 b.end 那一侧,而 begin/end 只是书写痕迹。

改成按两端规范秩排着掰、镜像最小规范秩更大的那一侧。全量:155 → 77

两条改动的分量差得很远,量了才知道

语料级变异 写法无关违例
去掉排序(退回按存储下标掰) 77 —— 没影响
一律镜像 end 那一侧 155 —— 全部效果在这

所以收益全部来自"挑哪一侧";排序那一条在本语料上一次都没触发。留着不是因为 量到了收益,是因为按存储序遍历本身就是头号契约的隐患 —— 照实记,不假装它有贡献。

单元判据这次建不起来,不留一条空的

试了两轮:先自己造三个多烯 —— 两个变异一个都没打红;再从真实失败集里挑三个 —— 还是没红。原因是那些分子是在审计自己的种子下失败的,而判据里的搅拌器 用的是另一套种子,抽不到那一次分岔。

没有区分力的判据就是空过的,所以删掉了,改用语料级变异(上表)当证据 —— 那是这个仓库里已有的口径(「线端不压字 3044 → 0」也是这么验的)。单元层面这一类 目前是覆盖缺口,记在这里。

±180° 是同一个方向,却排在序列两头:写法无关 77 → 23

修完顺反那一处再做一次分布,2-bfs(链/取代基)成了最大一块:32/39。

place_neighbours 把已占方向按角度排序,而角度来自 atan2,值域是 (-π, π] —— −180° 与 +180° 是同一个方向,却排在序列的两头。末位差 4.4e-16 就足以 决定它落在哪一端:

写法 A: occ = [-3.14159265358979312, -1.04719755119659808, 1.04719755119659763]
写法 B: occ = [-1.04719755119659808,  1.04719755119659763, 3.14159265358979267]

这是同一组方向(−180°/−60°/+60°),排出来却是两个不同的序列, largest_gap 看到的空隙序列跟着不同,取代基差 120°。

化到 [0, 2π) 之后两边都是 [1.047, 3.142, 5.236]。全量:

写法无关违例 77 23(−54)
有键交叉 3 0
—— 其中有键交叉 80 70
干净 16196 16194(−2)
有未解冲突 1123 1128(+5)
原子不重合(违例) 77 79(+2)
硬性质五条 0 0

这一处单元判据建得起来(与顺反那处不同):拿真实失败的两个分子跑 16 种写法比 指纹,去掉 rem_euclid(TAU) 当场红。

断点只是被挪了个地方,又踩了一遍

化到 [0, 2π) 之后再做一次分布,2-bfs 仍是最大一块。插桩看到:

写法 A: occ = [2.0944, 4.1888, 6.28318530717958534]
写法 B: occ = [0,      2.0944, 4.1888]

6.2831853…5342π − 1.8e-16 —— 一个 −1.8e-16 的角 rem_euclid 之后落到 了将近 2π,而 0 与 2π 同样是一个方向。断点从 ±π 挪到了 0/2π,同一个 bug 换了个位置。贴着 2π 的角一律掐回 0,23 → 9

"更彻底"的那版反而更差,是量出来的。 我先试的是"先量化再对量化后的整圈 取模、并拿量化值当角度" —— 听着更根治,实测 23 → 79。撤掉,换成上面那个 最小的掐回。没量就没法知道哪个对。

四处根因是同一族

表现 修法
largest_gap 三个空隙精确相等,> 比浮点 量化再比,平局取起始角最小
fix_cis_trans 镜像哪一侧看 b.end 按规范秩挑
place_neighbours ±π 是同一方向却排两头 化到 [0, 2π)
place_neighbours 0 与 2π 也是同一方向 贴着 2π 的掐回 0

都是"同一个几何事实有多种表示,而代码挑了书写痕迹给的那一种"。 头号契约的 违例从基线 223 一路降到 9,降了 96%。

单元判据 the_same_direction_always_gets_the_same_angle 拿三个真实失败的分子跑 16 种写法比指纹:前两个踩 ±π 那个断点,第三个踩 0/2π 那个。两个变异(去掉 rem_euclid、去掉掐回)各红一次。

挑方向那一步的次序与严重度次序相反 —— 第五个变体成了

chains::free_direction 挑方向是两轮:

if let Some(hit) = pick(&|t| clear(t) && uncrossed(t)) { return hit; }  // 第一轮
if let Some(hit) = pick(&|t| clear(t))                { return hit; }  // 第二轮
(ideal, block)                                                          // 兜底

每轮内部按 (same, pinch, depth) 取最不坏。于是「不交叉」是硬门槛,排在 「不压窄键角」前面 —— 第一轮只要有解,第二轮压根不跑,哪怕第二轮里有 (same=0, pinch=0) 的候选在排序键上严格更好。而本库的严重度次序是 假环 > 读错结构(窄角) > 看得见的丑(交叉) > 挤这两处是反的。

(还有更硬的一处:uncrossed 作为硬门槛排在 same(逐位重合 = 假环)前面, 即"看得见的丑"压过了"假环"。语料上这一档从没触发 —— 把 crossed 前移到 same 之前的变体与现状逐字节相同。)

实测坐实(第 4134 行)

插桩把 11 个候选逐个列出,见上一节那张表:6 个不窄的候选 clear 全是 1 —— 它们不是被原子占死,只是会与已画的键交叉;第一轮有解,于是那 4 个 (same=0, pinch=0) 的候选根本没被考虑。

四个变体,全量语料逐个量过

现状 crossed 进键第三位 两两栅格闸 绝对栅格闸
键角不过窄(硬判据) 8 0 7 18
原子不重合(硬判据) 2 2 2 2
写法无关(30 与 100 种写法) 0 0 0 0
干净 17126 17128 17130 17134
有未解冲突 185 182 180 176
有键交叉 90 128 113 100
—— 其中布局已退化的 34 62 47 34
其中只是标着退化,没有别的毛病 302 275 290 302
有取代基挤到另一根键上 17 14 17 17
有立体中心的取代基挤在一侧 4 0 4 4
  • crossed 进键第三位:合并成一轮,键变成 (same, pinch, crossed, depth)。 唯一能把 键角不过窄 归零的一版,改动半径 59 张,三次运行逐字节相同, 外部判官 496 一致 / 0 读反。
  • 两两栅格闸:只在枢纽的已占方向两两夹角都是 30° 整数倍时才让 pinch 压过 crossed(这个写法与坐标系无关)。
  • 绝对栅格闸:改看已占方向本身落不落在 30° 栅格上(与坐标系有关, 只因消冲突之前的姿态今天由构造钉死才成立)。

为什么前四个都没有采纳

crossed 进键那一版是唯一归零的,但它的代价不能用"那些图本来就标着退化" 带过:其中只是标着退化,没有别的毛病 302 → 275 —— 27 张原本被审计 认证"除退化标签外一点毛病都没有"的桥环模板图,现在有了看得见的交叉。 审计立这条指标正是为了挡住这种折扣。8 处读错结构换 27 张图失去无瑕, 按本库先前那次同类取舍的算法("拿 8 处共线换 68 处窄角加 38 处冲突,不划算"), 这一笔判不出明显划算。

两个栅格闸都不成立:一个只把窄角从 8 降到 7,另一个反而涨到 18。 (独立审核报的是"绝对栅格闸给出窄角 0、交叉 100" —— 复现不出来, 同样的闸我量到的是 18;这里只记我自己跑出来的数。)

第五个变体:把「坐标来自松弛」这件事结构性地传进去

前四个变体里,两个栅格闸都是在"这套坐标是不是从理想栅格算出来的" —— 一个只看已占方向两两之间的夹角(漏掉"只有一个已占方向"的情形),另一个看它们 本身落不落在栅格上(把非模板的东西也卷了进去)。别拿几何去猜,直接传。

布局时维护一个「离网」原子集:rings::layout_local 返回退化的那些系统,它的 原子全部入集;再沿摆放传下去 —— 从一个离网的枢纽挑出来的方向,落点同样离网。 chains::Env 多一个 off_grid 字段,pinch_floor 只在支点不离网时生效。

这与那条早就在的规矩是同一个道理:地板只在它标定的那套几何里有意义。 先前已经写着"度 4 的理想角本来就是 90°、度 6 是 60°,拿 89° 去拒它们等于拒掉 正解";松弛出来的坐标同理 —— 那里的"最窄角"量出来的是松弛器碰巧给了什么。

现状 纯合并 两两栅格闸* 绝对栅格闸* 结构门 + 合并
键角不过窄(硬判据) 8 0 7 18 0
非环枢纽 < 89° 的图 43 0
原子不重合(硬判据) 2 2 2 2 2
写法无关(30 与 100 种写法) 0 0 0 0 0
干净 17126 17128 17130 17134 17128
有未解冲突 185 182 180 176 182
有键交叉 90 128 113 100 113
—— 其中布局已退化的 34 62 47 34 47
只是标着退化,没有别的毛病 302 275 290 302 290
其中确有两个标签盒叠上 99 95 97 96 95
有取代基挤到另一根键上 17 14 17 17 17
有立体中心的取代基挤在一侧 4 0 4 4 4
改动半径 59 49

(带 * 的两列是上一轮的实测,这一轮没重测;其余各列本轮重跑过。)

外部判官 496 一致 / 0 读反;两次运行的逐图指纹逐字节相同。

「离网」不沿链传下去 —— 传下去是错的,而且审计看不见

第一版把离网沿摆放传了下去(从离网的枢纽挑出来的落点也算离网)。那是错的: 枢纽自己不在那个环系统里的话,它的所有已占方向都是 free_direction / place_clear 按 30° 档位挑出来的,相对角是规整的,89° 那道地板照样有意义。

实测坐实:传下去之后有 21 张图在非环枢纽上带着窄角,而那些角 精确是 30.00°(20 处)与 60.00°(3 处) —— 角度落在栅格上,正说明那里的 几何是规整的、不是松弛器给的噪声。

更要紧的是:这笔代价在硬判据上结构性不可见no_angle_is_pinched 开头就是 if !clean { return },而离网非空 ⇒ 记了退化 ⇒ 不干净。也就是说那 21 处窄角 永远不会被那条判据数到 —— 不是碰巧漏了,是查不到。

去掉传染的账,两面都记:

传下去 不传
非环枢纽 < 89° 的图 21 0
有键交叉 100 113
只是标着退化,没有别的毛病 302 290

按这次改动自己立的次序(窄角 > 交叉),21 处读错结构换 13 处看得见的丑, 该做。这一版取「不传」。

判据:先前这一整块是零覆盖

独立审核把这一点抓了出来 —— 纯合并那一版跑三个变异(crossed 挪到 pinch 前、挪到 same 前、整个去掉),168 条判据一条都没红。也就是这次改动可以 被人一行改回去而无人察觉。

补了三条,锚点都取自真实语料:

判据 锚点 打红的变异
a_pinched_angle_outranks_a_bond_crossing 第 4134 行(两套规范) crossed 挪到 pinch 前、挪到 same
a_bond_crossing_still_counts_it_is_only_ranked_lower 第 2284 行 crossed 整个去掉
a_relaxed_ring_system_does_not_get_the_ninety_degree_floor 第 780 行 结构门去掉

四个记录点合成了一个函数。 「记退化」与「记离网」原本分开写在四处,而实测 漏掉任何一处,现有判据一条都不会红 —— 这是判据覆盖不到的一类改动。与其加 四条判据,不如让那个状态在结构上表示不出来:两件事合进 note_degraded,四处 都调它。重构前后语料逐图指纹逐字节相同(0 / 17662)。

(第 780 行的退化记的是 template: Hit —— 查模板表命中,不是运行时松弛。 论证不受影响:模板坐标同样不在 30° 栅格上。代码里的措辞已经改成覆盖 "模板命中 / 弧法 / 松弛"三档。)

第三条的锚点换过一次:一开始取语料第 1 行,它虽然在两个变体下画得不同, 交叉数却没变 —— 变异打不红,判据是空过的。逐张比过桥环图的交叉数之后 改成第 780 行(去掉门当场从 0 处变 1 处),才真的钉住。

另一处次序倒置,顺手摆正了

uncrossed 作为硬门槛时排在 same(逐位重合 = 假环)前面 —— "看得见的丑" 压过了"假环"。合并成一轮之后 same 是键的第一位,这一处也正了。

第一版这里写着"语料上这一档从没触发,把 crossed 前移到 same 之前与现状 逐字节相同" —— 那句是错的,已删。实测把 crossed 挪到第一位改了 44 张图、 窄角退回 8、交叉退回 90;但那个变异同时也把 crossed 挪到了 pinch 前面, 两件事混在一起,分不出是哪一处在起作用。所以这里不再对"samecrossed 谁在前"下任何结论 —— 没量清楚的话就别写。

规范指纹漏判 8.9%:它打的那个量,布局一处都没用过

Depiction::style_fingerprint 存的是 Style::layout_fingerprint(),用来拦 「用 A 规范排版、拿 B 规范渲染」这种静默错配。而它打的是 clash_distance() —— 全仓搜过,布局一处都没用过这个量,它只出现在 style.rs 自己。

clash_distance = (label_size + 2*margin).max(0.35),是把两个字段有损地揉在 一起、还带个截断。于是造得出两套 label_size 差整整一倍、指纹却位级相同的规范:

atom_label_pt margin_width_pt label_size clash_distance 指纹
A 10.0 1.6 0.694 0.91666666666666663 0xd63d81415d7c7e19
B 5.0 4.10 0.347 0.91666666666666663 同上

拿这一对跑全量语料:8831 个分子里 787 个坐标不同,而 matches() 说"是同一套 规范排的"的有 8831 个 —— 一套专为拦静默错配而生的机制,8.9% 的错配拦不住

该打哪几项,是量出来的不是读出来的

逐字段扰动、比全量语料的坐标(8831 个分子)。只有两个旋钮:

旋钮 扰动 坐标变了的分子
chain_angle_deg 120° → 108° 6687
label_size() ×1.4(字号 ×1.4,或等价地键长 ÷1.4) 1409
同上 ÷1.4 727
其余九个字段 ×1.4 全 0
margin_width_pt ×0.01 一直扫到 ×50 全 0

atom_label_ptbond_length_pt 不是两个字段,是同一个旋钮的两头 —— 布局只经 Style::label_size() 读它们,而那正是二者之商。第一版这里写成 「atom_label_pt 1351 / bond_length_pt 340」两行,会被读成"改字号比改键长 影响大 4 倍",那是假的:两者差 4 倍纯粹因为一个往上乘、一个往下除。实测 对称性逐值吻合 —— 字号 ×k 与键长 ÷k 在 k = 1.36/1.37/1.38/1.4/2.0 上给出 1022/1351/1358/1409/4740,两列一个不差

这些计数对扰动幅度极敏感,引用时必须连幅度一起写:k = 1.36 → 1022, 1.37 → 1351,1.38 → 1358 —— 幅度差 1%,数字挪三成。它们说明的是"这个旋钮 动不动得了布局",不是"它有多敏感"。

验死这一条:保持 label_size()chain_angle_deg 不变、把其余十个 字段全改掉(键长与字号双双翻倍,线宽、粗宽、留白、虚线间距、双键间距、 图注字号、字体、名字全换),8831 个分子的坐标逐点相同

静态那一头也对得上:label_size() 的调用点在 label.rs(布局路径)与 render.rs;margin()bond_spacing() 只在 render.rs;clash_distance() 一个调用点都没有

所以指纹计入的正好是 (label_size(), chain_angle_deg) —— 不多不少。 clash_distance() 一并删掉:它没有任何调用方,而文档却写着"由调用方按实际 标签尺寸细化",那个调用方不存在;布局真正用的碰撞半径是 refine::radii, 按逐原子的标签墨迹盒算。

判据逐字段钉住覆盖面

the_fingerprint_covers_exactly_what_moves_the_layout 对 11 个字段各扰动一次, 比两件事:指纹变没变、坐标变没变,两者必须一致。少了就是错配拦不住, 多了就是换个线宽还要重排一遍。分子取自语料第 4134 与 498 行(不自己造), 合起来覆盖两个旋钮;并且断言"两边都真的出现过",免得一直在比 false == false

变异各当场红:把指纹改回打 clash_distance(即把这个漏原样放回去)、 只打链角、多打一项只进渲染的线宽、某一档扰动改成 *= 1.0(空过闸门)、 label_size().floor()(打了但有损)。

光有这张表还是脆的:它硬编码 11 个字段,而 Style 哪天多一个真影响布局 的字段,判据不会自动覆盖到 —— 实测加过一个 extra_angle_deg 并让 chains::ideal_angle 真的读它,五条判据全绿。所以判据里加了一段穷举解构 (不写 ..):加字段直接 error[E0027]: pattern does not mention field …, 编译期就拦住,比判据红更硬

改动半径 0 / 17662 —— 指纹不进图元,本来就该是 0。这个值只做运行时自比、 本仓哪儿都不落盘,而且改动是 fail-safe 的:存了旧值只会白重排一次,不会画错。

键角不过窄剩的那 8 处:局部决策是对的,事后也掰不动

键角不过窄(地板 89°)剩 8 处违例,7 处正好 60.0°、1 处 30.0°,全在度 2/3 的原子上。60° 恰是 chains::free_direction 躲让步长(30°)的两档,所以第一反应是 "躲让挪多了"。插桩之后发现不是。

候选表:宽的方向不是被占死,是会交叉

free_directionpick 加了一句"赢家是窄角就把整张候选表打出来", 第 4134 行(CCC(CC)C(O)(C(CC)CC)C(O)=O,一个季碳挂两条仲丁基加羧基)那一处 11 个候选逐个列全(clear = 落点不与已放原子重合,uncrossed = 不与已画的键交叉):

 #  最窄角  clear  uncrossed  same  depth
 0   120°     1        0        0    0
 1   150°     1        0        0    0
 2    90°     1        0        0   0.0538
 3   180°     1        0        0    0
 4    60°     1        1        0    0      ← 赢家
 5   150°     1        0        0    0
 6    30°     1        1        0    0
 7   120°     1        0        0    0
 8     0°     0        1        1   0.2500
 9    90°     0        1        1   0.2500
10    30°     1        1        0    0

第一版我把因果写反了,在这里纠正。 那 6 个不窄的候选 clear 全是 1 —— 它们不是被已放原子占了,是会与已画的键交叉。真正被原子占死的只有 #8、#9, 而那两个同时还要付一处重合。

挑方向那一步是两轮:先要"既不重合也不交叉",都腾不开才退而只求"不重合"。 第一轮就有解(#4),第二轮压根没跑 —— 而第二轮里那些 120°/150° 的候选 (same=0, pinch=0) 在排序键上是严格更好的

于是真正的结论不是"局部决策是对的",而是:"不交叉"被当成了硬门槛、排在 "不压窄键角"前面,而本库的严重度次序是 窄角 > 交叉 —— 60° 的拐角看着像个 三元环,那是读错结构;交叉只是难读。这两处是反的。 已单独立项去量, 这一轮没改。

事后掰:量过,不划算

现成的「撑开」算子(见下一节)能不能把它们掰开?口径先说死:11 处窄角落在 11 张图(分子 × 规范)、7 个语料行上,每张图恰好 1 处。逐张记代价:

能消? 代价
451 [ChemDraw] Δ交叉 +1,Δ深度 +0.079
2884 [ACS] / [ChemDraw] Δ重合 +1 —— 拿假环换窄角,该拒
2957 [ChemDraw] Δ交叉 0,Δ深度 +0.0038
4134 [ACS] / [ChemDraw] Δ交叉 +1,Δ深度 +0.054
892、2847 ×2、7969 ×2 不能 一个候选都消不掉

汇总(端点键放开之后):6/11 有候选能消;扣掉那 2 张"要制造一处重合"的, 剩 4/11;而这 4 张没有一张是免费的 —— 全都新添交叉或加深碰撞。 照搬 rotatable 的旧候选集则是 0/11

按严重度次序(窄角 > 交叉)付这笔账本来说得通,但收益 4 张、代价每张都要 新添交叉或碰撞,不划算。没有改。 记在这里,免得下次再查一遍。

顺带修的一处:撑开的候选集不该照搬 rotatable

上面那次测量暴露出一件事:splays() 直接用了 rotatable 给的键集,而那个函数 排除端点键,理由写在它自己的文档里 ——「端点键翻了等于什么都没做,那一侧 只有它自己,镜像回原位」。那条理由只对镜像成立。 把一个甲基绕它的邻居转 30° 不是对合变换,那个端基真的移动了(弦长 2·sin15° ≈ 0.518 个键长); 而剩下的那 11 处窄角,支点全都挂着端点键

已改成自己筛(不在环里、两端都已放置、支点度数 ≥ 2)。语料改动半径 0 / 17662 —— 闸门只在 4 个分子上开,赢家没换。改它不是因为量到收益,是因为照搬那条排除 等于按一个不成立的理由丢掉一整类候选,与前面 is_linear_centre 把砜的硫算成 sp 中心是同一类毛病。判据 a_terminal_bond_is_a_real_splay_candidate 钉住它, 变异(枚举换回 rotatable)当场红。

它不是死代码,但今天完全惰性 —— 这一点要照实记。 插桩量过:评估的候选从 184 涨到 280,新增的那 96 个全部被第一条"没消掉重合"拒掉,一个都没 进过排名,赢家一次都没换过。正面的一例是第 7880 行:它改前 splays() 返回 0 个候选,改后返回 8 个 —— 候选是真造出来了,只是都不合格。

筛选条件里 支点度数 ≥ 2 是冗余的早退:度 1 的支点只有 root 一个邻居, 后面"共线就跳过"那一支必然接住它。变异验过 —— 删掉它判据全绿、语料改动半径 0。 留着只因为它便宜,代码注释里也照实写了这一点,免得后人以为它在干活。

一条测量方法上的教训

第一次插桩一行都没打出来,我差点据此下结论说"不是 free_direction 干的"。 实情是那次构建失败了,而我把 2>&1 >/dev/null 之后只 grep 了自己的标记, 编译错误正好被滤掉。插桩探针要么先单独 cargo build 确认,要么 grep 里带上 ^error —— 否则"没有输出"会被读成"没有发生"。

顺反把两个环钉在同一处:加第四个算子「撑开」,重合 8 → 2

原子不重合 卡在 8 处(第 1068、1069、5000 行各两张,第 7880 行两张)。 上一轮的结论是「根子在顺反校正,而那一步没得挑」—— 那句仍然对,但结论下早了: 没得挑的是"镜像哪一侧",不是"只能认了"。

为什么理想几何下真的画不出来

逐点量过第 1068 行的坐标。三个分子是同一种构型:两个大取代基挂在相邻的环 原子上

  • 环上两根外向键落在外角平分线上,只岔开 60°;
  • 顺反规格(两根 Cis,参照原子是另一个亚胺碳)把两个 ipso 碳钉死在 (0.229, 0.632) 与 (0.229, −0.368) —— 相距正好 1.0,是同一个六边形的相邻顶点;
  • 于是两个对位取代的苯环占同一个六边形,逐位叠上 6 对。

三条出路,逐条堵死:

出路 为什么不行
换镜像的另一侧 两侧互为整体反射(等距变换),所有原子间距离逐一相同。插桩 930 次,两侧重合数每次都相等
翻转那个苯环/吗啉环 单点连接的六元环关于该连接轴自对称,翻了等于没翻
改布局次序 位置由「理想键长 1.0 + 理想键角 120° + 顺反规格」联合钉死,与布局怎么走无关
让布局阶段避开 布局摆开时一对重合都没有(分阶段计数:布局 0 → fix_cis_trans 6 → relieve 6),那时没有任何信号可用

所以只剩键角。而且这不是"破例" —— chains::free_direction 躲让时早就按 ±30° 一档铺开、最多铺 5 档,硬判据 键角不过窄 的 89° 地板正是为它设的。 撑开只是把同一把尺子搬到消冲突阶段,只走一档

算子

绕支点把一个分支整体转 30°。要动的那一侧由 far_side 给(更少的一边,平局按 最小规范秩),支点是不在那一侧的那个端点。键集不照搬 rotatable —— 那个 函数排除端点键,而那条理由只对镜像成立,见后面「候选集不该照搬 rotatable」一节。几何后果只有一处,可以证明:跨越两侧的 键只有一根,而支点是旋转中心 —— 键长全保;moved 内部是刚体变换;被转那个根 原子 c 处的角也不变(c→xc→p 同转,夹角不动)。只有支点处的角变了。

三条接受条件,全语料插桩量过(口径 audit … large.smi 1。这些计数随写法数 线性变 —— 每多一种写法就多跑一遍 generate,按默认的 30 复现会得到完全不同的数): 共评估 280 个候选,192 个因"没消掉重合" 被拒,余下 88 个全部合格。keeps_stereo 判否 0 次;角地板判否 4 次,而那 4 个 同时也没消掉重合 —— 两条都没有单独拦下过任何候选。两条都留着, 理由与 refine::score 那一位相同:次序/正确性不一致本身就是隐患,而且各自有 直接钉谓词的判据(拿分子去验会空过)。

闸门 best.0 > 0(还有原子精确重合)开了 16 次:12 次选出候选并撑开, 4 次一个合格候选都没有 —— 全是第 7880 行。它现在能构造出 8 个候选 (端点键放开之后;先前是 0 个),但 8 个全部因"没消掉重合"被拒,其中 4 个 连角地板也过不了。

定序键里故意没有 depth

方案原本把 (same, 交叉, q(depth), −q(最窄角), 秩…) 当选择键。方案审核实测指出: 第 1068 行有 4 个候选并列在 (same=0, 交叉=3)、最窄角全是 90.0° —— 胜负会完全压在 q(depth),而 depth 是按原子下标求和的浮点,同一分子 换种写法末位不同。头号契约现在是 0 违例,没有余量赌一个量化边界。已把 depth 拿掉,平局交给规范秩(单射,一定分得出)。

同一轮审核还纠正了我三个数:1068/1069 撑开后的最窄角是 90.0° 而不是 150° (150° 那档要多付 4 处交叉,第二项先分了胜负);第 7880 行是 6 对重合不是 "1+";键角不过窄 的基线是 8 不是 0。

账,两面都记

改前 改后
原子不重合 8 2
干净 17124 17126
有未解冲突 187 185
有键交叉 86 90 ← 付出去的
—— 其中确有两个标签盒叠上 98 99 ← 也付出去了
键角不过窄 8 / 17135 8 / 17137
写法无关(30 与 100 种写法) 0 0
其余六条硬判据 0 0
外部判官 496 一致 / 0 读反 496 / 0

交叉涨 4 是第 1068、1069 行那四张从 0 处交叉变成有交叉。按严重度次序该付 (假环是读者无从察觉的错,交叉只是难读),但不能不说。

改动半径 6 张 / 17662 —— 正是那 3 个分子 × 2 套规范,别处一张没动。 三次运行指纹与判据表逐字节相同。

六个变异,六条判据

变异 红的判据
去掉 sp 支点守卫 a_splay_never_pivots_on_an_atom_that_should_be_drawn_straight
闸门从「有重合」改成「有交叉」 two_rings_…_get_pulled_apart(单条)
闸门接受条件同改成交叉口径 上条 + chains 两条
闸门拆掉接受条件松成 better a_splay_is_only_spent_on_atoms_drawn_on_top_of_each_other
整段撑开关掉 two_rings_…_get_pulled_apart
转向固定 +30 在先 the_two_splay_directions_are_named_without_looking_at_the_canvas
角地板一刀切(不看原来的角) a_splay_may_not_pinch_the_pivot_below_the_floor
角地板整个去掉 同上

闸门那两条也要说实话:闸门与接受条件互为冗余。 只拆闸门,行为一个字节不变 (now.0 < best.0 在没有重合时永远为假);只松接受条件,闸门又不放行。同改 才会变行为 —— 实测那时 46 张图变了、键角不过窄 违例 8 → 11(撑开跑去消 次要缺陷,把键角白白压窄)。判据用的第 364 行正是变了的一张。

第一条起初没红,值得记:布局把炔画直了,于是 sp 中心的另一个邻居与 root 共线,splays 里"共线就跳过"那一条先一步把它挡掉 —— 判据是空过的。改成手摆 一份把炔中心画弯的坐标(pos 本来就是外部传进来的,守卫不能依赖布局的好意), 并先证明"共线拦不住"确实成立,才真的钉住。

转向那一条也要说实话:把它改回固定 +30 在先,全量语料写法无关仍是 0、 端到端那条写法判据(含 1068 的三种写法)照样绿 —— 因为消冲突之前的坐标系今天 本来就逐写法一致(起手环落在起始角为常数的正多边形上)。可没有任何判据 守着那个前提:端到端那条排在 orient::canonicalise 之后,姿态已被归一。 所以要一条直接验等变性的判据,它也是唯一红得起来的那条。

debug_assert! 从来没被闸门跑过 —— 37 处守卫等于没写

查另一件事的时候顺手撞见的:写了个探针画语料第 1068 行,一根顺反键都查不到、 一对重合都没有,与审计报的对不上。差别在一步 —— 审计的 prep 调了 omgkit_io::stereo::perceive_bond_stereo,探针没调。

那一步不在净化里,而且漏了不报错

omgkit_chem::pipeline::sanitize 的 12 步里没有双键顺反感知(它要用对称 等价类,那在净化的上一层,调不到 —— 这是有文档的分层决定)。只跑 parse + sanitize 就调 generate,每根双键的 stereo 都是 None, fix_cis_trans 整个空转,E/Z 可能画反,而线条本身看着一点毛病没有

omgkit-match::react 对同一件事早有 debug_assert! 守卫,omgkit-depict 没有。 把谓词 directions_not_perceived 从 react 私有处提到 omgkit_io::stereo(perceive_bond_stereo 的老家)两边共用,判据一并搬到函数 旁边。generate 补上守卫之后,depict 里当场红了 4 条现存判据 —— 它们画的分子写了方向键却从没感知过,也就是说那几条判据里的 E/Z 一直是被忽略的。

有意思的是 render.rs 里那段注释早就预言了这一刻:

它能绿只因为这里的 prep 不调 perceive_bond_stereo,布局看不见 E/Z, 顺反画成了同一张图。那等于把"顺式和反式必须画得一模一样"写成了硬性要求, 哪天 prep 对齐了它就红,而红的是判据自己。

分子当时就已经挑成"两套口径下都成立"的了,所以 prep 对齐之后 159 条全绿。

更大的那件事:这类守卫一次都没跑过

debug_assert! 在 release 下整段编译掉,而闸门里的测试那一档是 cargo test --release,clippy 只编译不运行。也就是说全工作区 37 处 debug_assert! 从来没有被闸门执行过一次 —— 包括 react 那两处、 far_side 里"规范秩有重复会静默退回存储序"那一处。写了等于没写。

已加第五道闸门 cargo test --workspace(debug),全工作区实测约 6 秒。 不用它替掉 release 那一档:大语料判据在 debug 下慢一个数量级。

改动半径 0 张 / 17662

改动半径怎么量:工具落地,以及我先前写错的机制

"这次改动碰了哪些图"这个量,README 里引用过六次,一直是每次手搓。做成常驻的了: 审计给第三个参数就落一份逐图指纹,口径直接复用「写法无关」判据那份图元多重集, 不另立一套。用法见 examples/audit.rs 的模块文档。

diff | grep -c '^<' 确实会多报,但我先前写的机制是错的

拿一次真改动量(ACS 的 bond_spacing_pct 18.0 → 18.5,两份 dump 键集完全相同):

join diff \| grep -c '^<'
报出来的 8393 8414

多出来的 21 行逐条查过,两边逐字节相同(例如 0000637:ChemDraw_…), 真变了的 8393 行一条不漏。所以机制是 diff 把邻近的改动并进同一个 hunk、 连中间没变的行一起打印。先前写在注释里的说法是"一处差异把后面的行整体错开、 于是同一处被数很多遍" —— 错的:LCS 会重新同步,每处真改动只数一遍; 真按那个说法误差该是全文件级的,而不是 21/8393 = 0.25%。已改正。

join 自己也有个坑:两边行数不等时只比交集,一声不吭

踩过一次:一份 240 行的文件顶掉了 17662 行的基线,比出来"240 张变了", 而真相是那两份没有一张可比。所以比较命令必须把"合上几张"一起打出来, 不等于语料张数就说明这次比较不作数。键里也并进了 SMILES —— 拿另一份语料的 指纹去比会在结构上配不上,n 直接掉到 0,不必靠人核对。

它量不到什么

图元多重集故意丢掉了只进渲染参数的量(线宽、字号、虚楔形间距 —— 这些不随 写法变,判据不该看)。代价是改这类参数半径恒为 0:实测 ACS 的 line_width_pt 0.6 → 0.9,每一张 ACS 图的每一根线都变粗了,两份 dump 逐字节相同。这不是缺陷,是口径的边界,已写进模块文档。

最后 8 处重合:根子在顺反校正,而那一步没得挑

原子不重合 从 79 一路压到 8。把这 8 张逐个拆开(第 1068、1069、5000 行各两张, 第 7880 行两张),结论出乎意料。

不是布局摆坏的

第一刀就问对了地方:分阶段数重合

布局摆开之后        0 对重合
fix_cis_trans 之后  6 对重合
relieve 之后        6 对重合   —— 消冲突撤不掉

是顺反校正造出来的。 stereo::fix_cis_trans 把一根双键的几何掰回去的办法 是把一侧的子树沿双键轴整体镜像;1068 是苯并咪唑挂两条一模一样的 N-芳基亚胺 臂,那一镜正好把两个对位取代的苯环逐位叠上(整整六对)。

消冲突够不着它:两臂之间夹着 C=N 双键,不可翻;而芳基那根单键可翻,但对位 取代的苯环关于那根轴近乎对称,翻了等于没翻。relieve 还有立体守卫 —— 真翻动了 顺反又反了。

「挑不撞的那一侧」是可证明无用

第二刀想当然:两侧都能镜像,那就挑不撞的那侧。写完实测 —— 一个数不动

插桩数清楚了:全量语料里"两侧都能掰"发生 930 次,每一次两侧的重合数都相等 (含 1068 那 12 次 cx=cy=6)。而这不是巧合,是可以证的:

镜像 X 侧得到构型 C₁,镜像 Y 侧得到 C₂,而 C₂ = C₁ 关于同一条轴的整体反射。 反射是等距变换 —— 所有原子间距离逐一相同,重合数、碰撞深度、交叉数全都 一样。

所以那一步根本没有可挑的。改动已撤回。(规范秩那条挑边规则仍然要紧 —— 两个 结果虽然全等,却是互为镜像的两张图,那关系到写法无关,不关碰撞的事。)

要修得让布局先知道顺反

真正的出路是布局阶段就把双键两侧的取代基摆到该在的一边,根本不需要事后镜像。 那要让 chains 在分配方向时读 BondStereo —— 是个结构性改动,没做,记在这儿

剩下第 7880 行那两张是另一类:重合在同一个环系统内部(镍四齿,五个环共用 一个金属),是 rings::layout_local 给出的解自己就自交,与上面无关。

顺带补了一处次序不一致

查这件事时发现 refine::score 的排序键是 (交叉, 深度) —— 没有"重合"这一位。 也就是说一次"消掉交叉、却把两个原子叠在一点"的翻转会被接受。全库别处的次序都是 假环 > 看得见的丑 > 挤,唯独这里不是。

补上了。它在本语料上一次都没触发(插桩实测 0 次 —— 那 6 处重合是顺反校正造 的,不是翻转造的),留着不是因为量到收益,是因为次序不一致本身就是隐患。 判据 a_phantom_ring_is_worse_than_any_number_of_crossings 直接钉这个次序、不靠 语料(拿分子去验会是空过的);变异"去掉那一位"当场红。

「退化(桥环等)」340 张里,302 张只是个诚实的标签

未解冲突压到 187 之后,退化(桥环等) 成了出图质量表上最大的一档(340,1.9%)。 但它和前一节那个 4 倍过报是同一类问题:这个数不能直接当缺口读

桥环用了模板表就记一笔 Degradation::BridgedRingSystem —— 那说的是"这不是从头 算出来的坐标",不是"画坏了"。而它排在 if-else 分档的第一位,于是:

  • 别的毛病会被它吃掉(未解冲突那一档因此少算了 118 张,见前面);
  • 反过来,它自己会被读成"340 张烂图"。

拆开看

340 张退化 张数
除了这个标签,一点别的毛病都没有 302 88.8%
有键交叉 34 10.0%
有标签真叠上 4 1.2%
有原子画在同一点上 0 0%

退化的种类也值得记一笔:346 条全是查表命中,外加 8 条 η 配位 —— NotInTableNoFingerprint 都是 0。也就是说模板表覆盖了语料里的全部 桥环骨架;而这也再次确认了弧法接进运行时那笔账上记着的债:那条路径在语料上 依然零覆盖

只加一档报告

审计新增 —— 其中只是标着退化,没有别的毛病。和上一节一样:没有去改 Degradation 的语义 —— "这坐标不是从头算的"该报就报,只是别让读者把它当成 340 处缺陷。

真藏在这一档里的是 34 张交叉 + 4 张标签叠上,而交叉那 34 张审计本来就单列着 (—— 布局已退化的 34)。

「挤压优先于交叉」这条次序,先前没有判据钉得住

rings::generator::pick_best 从短名单里挑一个,排序键是 (挤压, 交叉, 冲突, 重合, 塞不下, 共线)挤压排第一是量出来的:

排序 交叉 挤压 共线 180°
旧表(按骨架挑) 62 ~30 184
交叉优先 38 74 72
挤压优先(采用) 50 28 102

理由也写在那儿:共线可以补符号(渲染会给它补一个元素符号,读者还看得见)、 交叉读得出来,而取代基挤成一根是那个原子在图上彻底消失,没有任何补救

可这个决定一条判据都没守着。mol_key 改成 (s.0, …, s.5)(退回交叉 优先),157 条判据全绿;在更早的版本上跑同一个变异也全绿 —— 一直如此。

钉它要找一个"两个键指向不同候选"的骨架

写了个临时探针在几条骨架的候选池里搜「挤压更少、交叉更多」的对立:

金刚烷        候选 16 个,对立 19 对   挤压 [2,1,0,1,0,…]  交叉 [2,8,2,12,2,…]
双环[2.2.2]辛烷 候选 16 个,对立 30 对   挤压 [3,1,1,0,0,…]  交叉 [6,0,0,6,6,…]
降冰片烷       候选 16 个,对立  0 对   挤压 全 0            交叉 全 2

双环[2.2.2]辛烷最干净:一批是挤压 0 / 交叉 6,另一批是挤压 1 / 交叉 0。 实现必须挑前者 —— 宁可多六处交叉,也不让一个取代基挤没。

判据 a_cramped_substituent_outranks_a_bond_crossing 断言三件事:池子里真有 这样的对立(否则空过)、挑出来的挤压是最小的、而它的交叉不是最小的 (后者同时是第二道空过守卫 —— 两个键若指向同一个候选,这条骨架就验不出次序)。

变异 mol_key → (s.0, …, s.5):只有这一条红,报 挑出来的挤压 1 不是最小的 0

顺带把两条判据共用的"攒候选"那 40 行抽成 candidate_pool —— 各写一遍的话, 改了搜索预算只有一条会跟着变。

判据会悄悄变成空过:全库筛了一遍,补上 11 处守卫

这一轮画图质量大改(干净率 91.7% → 97.0%),审核在里面抓到两条前提失效的 判据:六叔丁基苯"平面上排不开"不再成立;the_whole_molecule_score 断言的是 第二键、HEAD 上碰巧绿。它们不是失败,是悄悄变成空过 —— 那正是隐患的定义: 判据还在,已经不查东西了,而且没人会知道。

抓到两条,就该问还有没有第三条。

筛法

depict165 条判据。风险类是「断言写在循环里,而循环可能被 continue / filter 整条跳过,又没有任何"跑没跑过"的计数」。脚本筛出 17 条,逐条读下来 其中 6 条是误报(断言在循环外、或已用 assert_eq!(compared, 12) 之类守着)。 真要补的 11 条

没有去猜哪条已经空了 —— 补完让测试自己说。 补上的当场抓到两处:

[ACS] c1ccc2ccccc2c1(萘):一根共用的双键都没查到
[ACS] c1ccccc1(苯)  :一个标签都没查到

萘的合拢键在它那个 Kekulé 式里是单键;苯整个分子没有一个标签(全是裸骨架碳)。 但这两条判据本身没有空过 —— 同一个名单里别的分子照查。所以守卫的粒度错了: 该问「整条判据有没有查到东西」,不是「每个分子都得查到」。守卫提到最外层之后 全绿。这一条本身也是收获:空过守卫放错层会假红

补完之后

depict 判据总数 165
循环可整条跳过、原本无守卫 17(其中 6 条是筛法误报)
补上守卫 11
补完仍无守卫 4 —— 断言在分子循环里无条件执行,只要名单非空就不可能空过

也就是说:165 条判据现在要么有空过守卫、要么断言无条件。 而且补完全部通过, 说明当前没有一条是空过的

改的全在 #[cfg(test)] 里,语料审计与改动前逐行相同

挑方向那一步从来不问"标签会不会压上":干净率 91.9% → 97.0%

上一节把靶子从 1204 张收窄到 323 张真叠上的,并指出九成涉及非环原子。这一节 是那件事的下文。

消冲突够不着这一类,而且是结构性的

520 对真叠上的里,425 对(81.7%)拓扑距离是 2 —— 两个原子挂在同一个原子 上。它们的夹角由 chains 定死,而 refine 的算子全是等距变换(绕某根键 镜像),动不了它们的相对位置。挑方向那一步不管,就没人管了。

而挑方向那一步(chains::free_direction)只查两件事:clear(新位置不落在已占 位置 0.1 个键长之内)、uncrossed(新键不与已画的键交叉)。"这个标签会不会压 到别人身上",一个字都没问。

先确认避让本身没错:把它整个关掉(一律用 allocate 给的方向),原子不重合 8 → 1013、真叠上 323 → 864。避让是大幅正收益的,缺的只是这一问。

三笔改动,逐笔量过

基线 ①实时 occupied ②+单原子打分 ③+窄角守卫(取它)
干净 16228(91.9%) 16228 17145(97.1%) 17124(97.0%)
有未解冲突 1086 1086 167 187
—— 其中确有两个标签盒叠上 323 323 93 98
键角不过窄(硬判据) 11 11 168 8
原子不重合(硬判据) 8 8 10 8(逐条相同)
其中有键交叉 86 86 84 86
有取代基挤到另一根键上 12 12 17 17
骨架被摆成 180° 124 124 129 129
写法无关(30 / 100 种写法) 0 / 0 0 0 0 / 0

occupied 是陈的 —— 这是先前就有的缺陷,而且几乎是潜伏的。 它是循环 开始前的快照;同一批兄弟摆下去只进 taken/drawn,从不进 occupied。 而 narrowest(t)(既管"同一档先试更宽那侧"的排序,也是判"这个方向会不会压窄" 的唯一依据)只看 occupied —— 于是看不见刚摆下的兄弟,而真正撞上的正是兄弟。

单独修它,审计表上每一个计数都不动;但坐标不是一动不动 —— 逐张比指纹, 24 / 17662 张变了(例如第 4909 行 ChemDraw 那张,窄角从 13–12–15 变成 10–12–13,同一个原子换了一对邻居)。它先经 ranked 那个"更宽优先"的排序键 显形(基线上 narrowest 就已经在被它消费),到第③笔才真正吃劲。不假装它自己 有收益,也不夸大成"完全不动"。

②单原子也打分。 上一轮为环系统写的 Lookahead::cost,在没有块时直接返回 零代价;改成把这个原子自己当成只有一个点的块走同一个 block_cost

③单靠②会把好图画坏。 pick所有档位里取代价最小,理想角那一档不再 优先 —— 为了让标签分开而偏两档,120° 就成了 60°。全量实测 168 处窄角,其中 156 处正好 60.0°,而这个库自己写着「60° 的拐角看着像旁边有个三元环,那是让人 读错结构」。我还核过这不是"判据范围变大"的假象:逐张打出"干不干净 / 窄不窄"再跨版本 join —— ①+② 下有窄角的 168 张里,100 张在基线上本来就是"干净且不窄"的,是真回归; 其余 68 张基线上不干净,判据当时压根没查到它们。

所以代价要三位 —— (逐位重合, 会不会压出窄角, 标签碰撞深度),正是这个项目立的 严重度:假环 > 读错结构 > 难看。窄角这一位只在枢纽度数 ≤ 3 时启用,与审计 键角不过窄 同一个适用范围(度 4 的理想角本来就是 90°、度 6 是 60°,拿 89° 去拒 它们等于拒掉正解)。补上之后窄角 168 → 8,比改动前的 11 还少

账,以及代价

  • 原子不重合 8 处与基线逐条相同;窄角修好 9、新增 6、两边都窄 2,净 −3。
  • 改动半径 1340 / 17662 = 7.59%(上一笔环系统那次是 1.03%)。它触及每一次原子 放置,这个量级是预期内的。量法:坐标量化成 1e-6 的整数逐张比 —— 用 diff< 行会多算 6 张(上下文位移),要用 join
  • 代价两档各 +5:取代基挤到另一根键上 12 → 17、骨架 180° 124 → 129。 新增的 5 张挤压全在桥环/笼状骨架上(第 780、2023、2024、4235 行:氧杂笼、 两个金刚烷衍生物、奎宁环),那些布局本来就报着退化。
  • 外部判官 496 一致 / 0 读反,不变。

两条判据,三个变异,各红一次

判据 变异 变异后
two_labels_on_the_same_atom_do_not_get_stacked_on_each_other(第 4000、5219、6226 行:磺酰类) 单原子不打分 原子 2 与 3 挂在 1 上,两个标签盒叠着
dodging_a_label_must_not_pinch_the_bond_angle(第 367 行:苯环两个间位硝基) 排序键去掉 pinch 原子 17 处 18–17–19 的夹角只有 60.0°
同上 occupied 换回陈快照 同上

第三个变异正是①那条既有缺陷的证据:它不是空转的,只是先前没人用到它。

two_labels_... 列的三个分子里只有第 6226 行那个双甲磺酸酯吃劲,另两个在 变异下也不叠 —— 它们是同族别的摆法的回归锚。判据的文档里写明了,免得后来的人 以为换掉第一个也照样红。

四笔记在案上的

  • 两条硬判据的分母变了。 键角不过窄 只在"没退化且没未解冲突"时才查, 查到数从 16236 涨到 17135(+899,正是②③修掉的那批)。发生率 0.068% → 0.047%,方向没错,而且新进来受检的恰恰是改动最狠的那 899 张。 键长全等(16236 → 17135)、环内双键(12850 → 13597)同理。表里的 11 → 8 是绝对数,读的时候要记得分母。
  • mol_key 抽出来之后,没有任何判据钉得住那六项的次序。 把它改成 (s.0, …, s.5)(等于把"挤压优先"整个推翻),157 条判据全绿 —— 基线上跑 同一个变异也全绿,所以这不是这次引入的空档,是一直存在的。 已经补上了,见下一节。
  • refine 那条判据换的分子只对 ACS 成立。 六硝酸铈在 ACS 下 6 处未解, 其中三对相距正好 1.000(一个键长塞不下 ON⁺(O⁻)=O,与布局无关); 另三对相距 0.518 = 2·sin15°,那是同枢纽相隔 30°,属于布局挑出来的。ChemDraw 下剩的 2 对全是后一类。判据写死了 ACS 所以站得住,但"与布局挑得好不好无关" 这句只对前一类。
  • pinch 与审计的适用范围差两小条(审计还跳三元环内角;这里的度数量在摘细 副本上)。都查过走不到,写在 chains.rs 的注释里。

顺带修好两条本来就带病的判据

  • refine::what_cannot_be_fixed_is_reported_not_hidden六叔丁基苯当"平面上 排不开"的例子。挑方向那一步一学会看标签,它就画干净了 —— 六个叔丁基的甲基 落在半径 3 那一圈,周长 18.8 个单位、十八个甲基各要半个单位,绰绰有余。那个前提 是化学直觉(真实分子极度张力),而判据要的是几何事实。换成六个硝酸根配位 的铈(第 449 行):一个键长的间距塞不下 ON⁺(O⁻)=O,这与布局好坏无关。
  • rings::generator::the_whole_molecule_score_is_what_decides 本身写错了:它 断言挑出来的候选交叉最小,可 pick_best 的首键是取代基挤压,交叉只是 第二键。HEAD 上两者碰巧指向同一个候选,所以它一直是绿的 —— 布局一动就露馅。 改成问完整的排序键,而键从实现里取(mol_key),判据不自己抄一遍次序;骨架 也从双环[2.2.2]辛烷换成金刚烷(前一条上"Quality 第一名不是整分子最优"这个前提 在新布局下不成立,判据自己的守卫拦下来说"该换骨架")。变异"整分子打分从排序键 里去掉"仍然当场红。

顺带:MolScore 的文档写五项、类型是六元组,漏的正是那个首键,已补。

「未解冲突」这个数过报了四倍:决策端用圆是对的,报告端不该也用圆

有未解冲突 一直是出图质量表上最大的一档(1086 / 17662 = 6.1%)。动手改它之前 先量了一遍 —— 结论是这个数本身不能直接当缺口读

先把它拆开

审计报的 1086 是分档之后的数(退化的图归到"退化"那一档去了),实际有未解 冲突的是 1204 张、2839 对

挤到什么程度(侵入比例) 对数
0–5%(几乎只是贴上) 470 16.6%
5–15% 468 16.5%
15–30% 1076 37.9%
30–50% 614 21.6%
50–75% 163 5.7%
75–100%(几乎重合) 48 1.7%
谁跟谁撞 一张图里几处
都不在环上 50.6% 1 处 39.2%
一个在环上一个不在 40.1% 2 处 31.7%
两个不同环系 8.3% 3–5 处 20.3%
同一个环系内部 1.0% 6 处以上 8.8%

九成涉及至少一个非环原子,六成发生在 ≤20 个原子的小分子上 —— 不是大分子 专属的问题,也不是"画成一团",多数是一两处。

关键的一刀:碰撞半径是外接圆,而外接圆是上界

refine::radii 取的是标签盒的外接圆(half_w.hypot(half_h))。那一步用圆是 对的,而且是论证过的:消冲突跑在 orient::canonicalise 之前,最终朝向还没 定,实测六成图的最终转角不在 90° 的倍数上,在那儿摆一个轴对齐的矩形方向就是错的 —— 圆是"对朝向一无所知"时唯一诚实的形状(见那一节)。

报出来的时候朝向已经定了。这时可以问一句更准的:那两个标签盒到底叠没叠。

2839 对未解冲突 对数
两端都有标签:盒其实分得开 910 32.1%
两端都有标签:盒真的叠上了 520 18.3%
只有一端有标签 1208 42.6%
两端都是裸骨架碳(没有标签可比) 201 7.1%

两端都有标签的 1430 对里,910 对(64%)盒根本不重叠。 按图算:1204 张里 只有 323 张(1.8%)真的有两个标签盒叠在一起

所以只加了一档报告,一行画法没动

审计新增 —— 其中确有两个标签盒叠上没有去改 refine 的判据 —— 决策端的模型该保守(宁可多消一处),报告端该准确(别把上界当事实),两者本来 就不必是同一个。

裸骨架碳那 201 对不进这一档:它们根本不画符号,"两个顶点离得近"是另一回事 (读者能不能分清那是两个原子),不是"字压到字",拿标签盒去问它没有意义。

这一档现在也是下一步该往哪儿使劲的指针:要压的是那 323 张,不是 1204 张。

挂环的方向:只看一个原子,看不见它身后那一整块

上一节把「锚点自由」那一路修好了(重合 79 → 57),末尾记着一笔账:从一根键挂 上去的那一路几乎没有选位自由。这一节就是那件事,重合 57 → 8

相切不是巧合,是能算出来的

六配位金属挂四个吡啶(语料第 6398 行):配体按 60° 分开,配体氮到金属一个键长, 环心到氮又一个键长。把金属放原点、配体 A 沿 0°:

N_A = (1, 0)        环心 C_A = (2, 0)
N_B = (0.5, √3/2)   环心 C_B = (1, √3)          两环心相距 |C_A − C_B| = 2
正六边形外接圆半径 = 1        两圆半径和 = 2 —— **精确相切**

环 A 的顶点从 N_A 那个方向起每 60° 一个:180°, 240°, 300°, 0°, 60°, **120°**
环 B 的顶点从 N_B 那个方向起每 60° 一个:240°, **300°**, 0°, 60°, 120°, 180°
C_A + (cos120°, sin120°) = (1.5, 0.866)
C_B + (cos300°, sin300°) = (1.5, 0.866)      ← 同一个点

切点正好是两个环各自的一个邻位碳,于是逐位重合。这不是浮点抖动能造成的, 是 30° 栅格 + 单位键长把它逼出来的。

挑方向那一步看不见这一切

chains::free_direction 问的是"这个原子落在这儿撞不撞"。可配体氮是一整块 环系统的接口 —— 环摆下去要占十来个格点,而这一步对此一无所知。

补法是把整块算进候选打分:layout_local 的结果做刚体变换,真算出来,不是估计。 块的坐标本来就要算(路 B 摆的时候要用),所以提前算一次、前瞻与真正摆放共用 同一份,不是多算一遍 —— 全量审计反而从 75.3 s 掉到 70.0 s

两处不这么写就会塌

一,不能写成布尔的「整块不撞」。 一票否决会拿"蹭一下"去换"精确重合":某个 取代基为了躲开一处深度 0.0538 的轻微重叠跳到别的档,给后面的兄弟挖了坑; 轮到兄弟时一个候选都满足不了,于是掉进兜底那一档退回 ideal,而 ideal 恰恰是 唯一逐位重合的那个。实测那一版重合 57 → 20 的同时新坏 12 张,签名全是 "度 4 枢纽、60°、环心距 2.000"。

改成"每一轮里挑最不坏的"就没有兜底那条路了。排序键是 (逐位重合的对数, 碰撞深度),重合对数必须排在深度前面,而且这一位是吃劲的:只按深度跑一遍 全量语料,原子不重合 8 → 10(坏的是第 3469、3472 行两个吡啶配合物)。道理 是一次精确相切只有一对原子重合、深度 (0.5 − 0)² = 0.25,而"整块蹭到三四个原子 上"一处都不重合、深度和却更大 —— 光看深度会主动选中相切

(这一条我先前照方案评审的说法写成"只按深度逐字节没变",是错的;审核量出来 差 2 张,我自己复跑确认了。数字必须自己量过才能写进来。)

二,兄弟的块必须在循环里累积。 place_neighbours 是一次算完全部方向才返回 的,调用方要等它返回之后才往 pos 里插。不在循环里累积的话,同一个枢纽上 "这个环撞那个环"从头到尾看不见 —— 实测代价是键交叉 72 → 110(+53%)。

顺带记一笔试过更差的:"开跑前先把所有兄弟按各自的理想方向摆一遍"反而更坏 (重合 44)。那些理想位置本来就是要被挪走的,先摆上去等于制造一堆幽灵冲突, 第一个决策就被逼歪,后面连锁。贪心的"只看前面"比"看一圈假的"好。

账:49 修好、0 新坏、8 处够不着

逐条 diff 了两次的违例清单,不是只看总数。

上一节之后 本节之后
原子不重合 57 8
有未解冲突 1120 1086
干净 16202(91.7%) 16228(91.9%)
其中有键交叉 72 86
写法无关(30 / 100 种写法) 0 / 0 0 / 0
外部判官 496 一致 / 0 读反 不变
全量审计耗时 75.3 s 70.0 s

改动半径 182 / 17662 = 1.03% —— 把每张图的坐标量化到 1e-6 做指纹,与基线 逐张比出来的。这个数应该进验收单:谓词一旦写宽,它立刻不是百分之一量级。

代价是 14 处键交叉(72 → 86)。 按上一节已经立好的次序 —— 重合是看不见的 假、交叉是看得见的丑 —— 该做,但不藏。

这 14 处试着还过,还不上

先把它量清楚:基线 72 张有交叉,现在 86,新增 18 张、消失 4 张。新增的那 18 张是 9 个分子 × 2 种规范,而且是同一族 —— 膦上挂三个苯环(第 639、645、 8492 行)、季碳挂两三个苯环(第 5003、7794、7795、7914 行)、金属挂四个甲基吡啶 (第 3469、3472 行)。正是前瞻要救的那一族:重合消掉了,环却穿了过去。

而且它们不是"拿重合换来的":86 张有交叉的图里,只有 4 张是这一轮从重合 里救出来的,其余是布局整体挪动(182 张)的连带。

然后试着把「整块新添的交叉数」加进 Lookahead::cost。三种排法都跑了全量:

排法 原子不重合 有键交叉 未解冲突 干净
(重合, 深度)(现在这版) 8 86 1086 16228
(重合, 深度, 交叉) 8 86 1086 16228
(重合, 交叉, 深度) 8 82 1090 16228
(交叉, 深度, 重合) 24 70 1096 16226

交叉排第三位是完全空转的 —— 逐项一个数不动。 道理是这一轮的第一轮谓词 已经要求"新加的那根键不交叉",而块会穿的候选,它的锚点键通常也穿;真正的平局 (深度都是 0)里,排在最前的候选本来就已经是交叉最少的那个。排第二位买到 4 处 交叉、赔掉 4 处未解冲突,是个平局。

所以没改 —— 加一项白算的开销没有道理。交叉那 198 个键对的去向另有分布: 53% 是两个不同环系之间、35% 是一根环键对一根非环键,而且 107/198 落在本来就 退化的布局里(桥环的松弛解自己就交叉)。真要压它,得动的是别处,不是这一步。

剩下 8 处是前瞻在结构上够不着的两类:

  • 第 1068 / 1069 / 5000 行:两个环挂在 BFS 的不同枢纽上,从来没有哪一次 place_neighbours 同时看得见它们。要修得把前瞻提到 BFS 之上。
  • 第 7880 行(Ni 四齿):重合在同一个环系统内部,是 layout_local 的解自交。 与这一步无关。

两处记在案上的

  • 两个待放邻居落在同一个未摆系统里这一路,语料里没出现(全量 8831 个分子 只有 1 条配位键)。它靠"同一个系统只规划一次"加"已经被兄弟摆上的邻居跳过" 两道闸挡着 —— 有推导,没有语料覆盖
  • 紧跟在快路径后面那段"老路"(同一个原子还挂着别的未摆系统)全量语料 一次都没跑过(插桩数出来是 0 次)。它要求一个从链上够得到的割点同时属于 两个未摆系统,那需要 ≥5 配位的中心。留着是为了不漏,但它是未被验证的分支

两条判据,分别只红一次

判据 变异 变异后
a_ring_hanging_off_a_bond_is_not_dropped_onto_its_neighbour(第 6398 行,六配位钴挂四个吡啶) blocks 换成空表(前瞻拿不到块) 原子 8 与 20 相距 0.0000
when_nothing_is_clean_the_least_bad_direction_wins_instead_of_the_ideal_one(第 7794 行,季碳挂三个苯环) pick 换成布尔式一票否决 原子 14 与 26 相距 0.0000
how_many_atoms_land_on_top_of_each_other_outranks_how_deep_they_press(第 3469 行,钴挂四个甲基吡啶) block_cost 只返回深度 原子 12 与 20 相距 0.0000

关键是它们各自只红一次:任一变异下另外两条都仍是绿的 —— 三条守的不是同一 件事,谁都不是另一条的影子。三条都先断言前提(分子必须真有若干个环系挂在同一 个原子上),否则就是空过。

第三条是审核逼出来的:改动里那一位(same)先前没有任何判据守着 —— 把它拿掉,553 条判据全绿,只有全量语料会掉两张。

摆环系统:参照物错了,而且摆完不看撞没撞

两条硬判据的违例追到了同一个函数:rings::place_at

先量清楚

原子不重合 79 处违例,距离全是 0.0000 —— 不是"挤了一点",是两个原子被摆到 了同一个点上。图上两根键首尾相接,读者会看到一个分子里没有的环,而且没有任何 办法看出那个环是假的。正好 0 也不奇怪:键长定死为 1、键角走 30° 栅格,所有原子 都落在同一张格点上,两块折回来就是逐位重合。

探针把 291 个重合对按拓扑分了类:

两个不同环系统 274(94%)
同一个环系统内 12
一个在环上一个不在 5
都不在环上 0

样例几乎全是挂着几条一模一样配体的金属配合物:三(乙二胺)合钴/铬/镍、 四(乙酰丙酮)合钍/锆、六(甲基吡啶)合钴。

毛病一:参照物是整块的质心,不是外角平分线

place_at 的契约是"让 anchor 落在 at,并让整体质心dir"。 单环时这没错 —— 质心就是环心,而环心正落在外角平分线上;稠环上就分家了

喹啉的环氮挂一根环外键(金属配体、苄基都算):两个六元环合起来的质心偏向第二个 环,把它摆到键的延长线上,整个环系就被拧了一个角,环外键与两根环键不再对称:

正六边形边长 1,环 A 心在原点,N 在 (0, 1),两个稠合原子在 (±√3/2, ±1/2)
十个原子的质心 = (√3/2, 0)
质心 − N = (√3/2, −1)        辐角 −49.106°
环外键方向 = −49.106° + 180° = 130.894°
N 的另一根环键方向          = −150°
夹角 = 360° − (130.894° + 150°) = 79.106°

而全量语料上「键角不过窄」剩下的 16 处违例全是这一族,值一模一样(79.1°)。 值一样正是几何被定死的证据,不是巧合。改成外角平分线之后这一族清零, 单元判据的变异实测打出 160.8934° / 79.1066°,与上面手算的对得上。

毛病二:摆完不看撞没撞

away_from 那条退化分支从前的注释写着"已放置的部分正好以 from 为质心 —— 任何 方向都一样,取固定的保证可复现"。"任何方向都一样"是错的:质心为零只说明已放 的东西关于锚点大致对称,不说明它们均匀铺满

三条一模一样的螯合环就是这么塌的:第一条摆好,第二条摆到对面(质心归零), 第三条拿到固定的 (1, 0) —— 和第一条逐位重合。而这一路摆完根本没有任何一步 去看撞没撞上。

改法是在锚点周围按 30° 一档铺一整圈候选方向,每个方向再配上一个镜像(平分线定死 了旋转,还剩一个绕轴的反射),挑最不撞的那个。away 排第一,所以它本来就不撞时 输出与从前逐位相同。

打分怎么排:五种都跑了全量语料

打分 原子不重合 有键交叉 取代基挤压 未解冲突 干净
基线:质心参照,不选位 79 48 28 1130 16192
平分线参照,不选位 79 52 28 1135 16187
深度在前,交叉破平局(取它) 57 72 12 1120 16202
只看深度 57 73 12 1119 16202
交叉在前 77 54 22 1120 16202
(撞上的对数 + 交叉数, 深度) 79 58 28 1120 16202
(撞上的对数, 交叉数, 深度) 72 63 33 1120 16202

重合与交叉几乎是 1:1 的取舍,五种排法只是在同一条前沿上滑动,没有一种能把 两者一起压下去 —— 选位买到的每一处重合,大约要还回一处交叉。这个代价不藏: 交叉从 48 涨到 72。

第二行把两笔改动拆开了:换参照物只让交叉 +4,那 +21 处全是选位的代价。

取深度在前,理由是两者不同级:「原子不重合」是判据里的硬性质,叠在一点会造出 一个假的环,读者看不出来;「键交叉」只是质量分档 —— 交叉是看得见的。宁可多 一处看得见的丑,不要多一处看不见的假。

refine::score 把交叉排在深度前面,与这里相反,不矛盾:消冲突动的是已经摆好 的图,那时深度差一点只是"挤";这边是在决定摆哪儿,深度大到极限就是重合。

两条判据,都变异验过

判据 变异 变异后
the_bond_that_carries_a_ring_system_lands_on_the_symmetry_axis_of_its_two_ring_bonds 参照物换回质心 160.8934° / 79.1066°
three_chelate_rings_on_one_metal_do_not_land_on_top_of_each_other 候选截成第一个 原子 0 与 6 相距 0.0000

两个分子都取自真语料(第 5354 行的喹啉合铜、第 2536 行的三乙二胺合钴),不是造的。 第二条先断言前提:分子必须真有三个环系统共用同一个原子,否则它查的根本不是 「以锚点自己为锚」那一路。

一句话账

之前 之后
原子不重合 79 57
键角不过窄 25 9
有取代基挤到另一根键上 28 12
有未解冲突 1130 1120
干净 16192 16202
其中有键交叉 48 72
写法无关 0 0

79 → 57 是净值:修好 23 张,新坏 1 张

审核逐条 diff 了两次的违例清单,不是只看总数。新坏的那张在语料第 1986 行:

C1=CC=C(C=C1)N2N=C3N(C2N3C4=CC=CC=C4)C5=CC=CC=C5
    原子 4 与 22 相距 0.0364 个键长(两种规范都违)

两个苯环,改动前不违例。不是"三个环内邻居"那一路:给 place_candidates 加上 「恰好两根环键才用平分线」的闸之后,这张图一个数没变,连整份重合清单都逐行 相同 —— 我原来的诊断错了,记在这儿。

它属于上面那条 1:1 前沿上的一次亏损,根子与剩下那 57 处同一个:从一根键挂上去 的那一路几乎没得挑(方向被键定死,单环的镜像又是空的,见下)。要修得回到上游 让 chains 给锚点换方向,而那要先知道后面会挂一整个环 —— 没做。

单环的镜像是空的:这一步只补得上一半

平分线定死旋转之后剩一个反射,但正多边形关于"顶点—圆心"那条轴是对称的,而 平分线正是这条轴 —— 于是单环的两个镜像是同一个点集,只是原子编号互换。

所以选位真正起作用的只有"锚点自由"那一路(螺环 / 割点,整圈方向都能试)。剩下的 57 处重合里那一大族(N#CS[Co](SC#N)([N+]1=CC=CC=C1)(...),两个吡啶配体逐位重合) 这个函数够不着。账上的 −22 全是前一路挣的。

逐字形标签盒:塞不下 1146 → 859

「有标签在键上塞不下」是出图质量表上剩下最大的一档 —— 1146/17662(6.5%)。 前面那节(「键线在标签外停得太远」)把它从 2.77% 压到 1.26% 之后写着"要再降得 做逐字形的盒",这一节就是那件事。

一个盒答不了两个问题

Label 先前只报一个数对 (half_w, half_h):整串的外接盒。裁键、量画布、 消冲突半径,三处都问它。可外接盒答的是"这个标签最多占多大",而裁键要问的是 "字在哪" —— 两者差得很远:

外接盒 字实际在的地方
Fe²⁺ 正上方 0.719 em 0.359 em(Fe 自己的半个字高)
NH₂(氢竖排在下)横向 0.528 em(H₂ 那行) 0.285 em(N 的墨迹右沿)
O⁻ 横向 到减号右端 O 的墨迹右沿 —— 减号悬在半空

第一行是上标把盒撑高、盒又上下对称,于是符号正下方那一整片纯空白也算进了 盒里。实测 [NH2+] 从正上方接一根键,白让 0.25 个键长

逐段不够,要逐字

先做的是逐段(每个 Run 一个盒),塞不下降到 1003。再往下卡在上标下标: 一段之内也不齐。- 在 Helvetica 里的墨迹是 232…322(千分之一 em)—— 一根悬在半空的细杠,占的高度不到字高的八分之一;而 +0…505,一直垂到 基线。把它们统统按"一整格字高"避让,O⁻—P 那根键就得从减号右边才起笔, 而减号底下明明空着。

所以取的是逐字形:每个字按自己的 AFM 墨迹范围 B 算盒。这张表与原有的 字宽表同源(WX 那一栏本来就在用),现在把 B 那四个数也收进来。

margin 要撑开盒,不是沿键加

第二件事同样量得出来。先前是"沿键的方向走出盒,再加一个 margin"。这在轴对齐时 等于四周留白,斜着来的键就不是 —— 那一段 margin 是斜着量的,投到垂直于字的 方向上只剩 margin × cos,擦着盒角进来时几乎为零。

改成先把盒按 margin 撑开再求交:端点落在撑开后的边上,离字的 L∞ 距离恒等于 margin,与来向无关。RDKit 的 MolDraw2D::adjustBondEndForString 也是这么做的 (逐段的 StringRect + doesLineIntersect(…, padding))。

四个模型全量实测(两个维度各两档):

模型 塞不下 垂直于字的空隙
逐段 · 走出盒再沿键加 margin 477 斜着来时 < margin
逐字形 · 走出盒再沿键加 margin 396 斜着来时 < margin
逐段 · 盒撑开 margin 1003 = margin
逐字形(真墨迹)· 盒撑开 859 = margin

396 最好看,但上面那两行是拿留白换来的 —— 它们不满足"四周留白"这句话本身。 两个维度是正交的:逐字形在两种 margin 语义下都赢(477→396、1003→859),而撑开 在两种盒模型下都更贵。取最后一行:比基线降 25%,而留白的保证是严格的。

判据

判据 守什么 变异
each_glyph_box_is_where_that_piece_of_text_actually_lands 盒与落笔是两条算路,把它们绑在一起 基线不下沉半个字高 → 红
a_bond_never_starts_from_inside_a_two_letter_symbol 字形表整张过一遍;字母缝里的净空 > 0 r/P 的度量抄错一位 → 红
a_bond_stops_at_the_glyphs_not_at_the_box_around_the_whole_string 切得够紧:外接圆 > 整串盒 > 字形盒,三级都断言 退回整串盒 → 红
bonds_stop_short_of_atom_labels 端点离最近的字正好一个 margin 同上 → 红
a_line_end_gets_pushed_a_full_margin_clear_of_the_glyphs 平移线那条路的留白与主线是同一个 去掉撑开 → 红
a_glyph_the_bond_flies_past_does_not_make_it_stop 射线完全错开一个盒时不该停 去掉 t0 > t1 → 红
the_glyph_boxes_land_below_the_symbol_when_the_hydrogen_does 画布侧翻 y 的符号 去掉负号 → 红
no_drawn_line_runs_across_an_atom_label 切得够远:没有线端点压在字上 上标当整格字高 → 红
全量的 线端不压字 同上,17584 处 0 违例

each_glyph_box_… 是关键一条:inkbuild 算、落笔由 lines() 算,先前 没有任何东西把两者绑在一起。判据从 lines() 独立重算每个字该在哪,字形度量 从 AFM 另抄一份,连每行居中用的行宽也自己加 —— 借实现的 Run::width_em 的话, 单字符那一行的前进宽度抄错会两边一起偏、正好抵消

bonds_stop_short_of_atom_labels 换了口径:先前是"离盒心多远",而外接盒里有 大片没字的地方。现在量的是离最近的那个字形盒的 L∞ 距离,并断言它正好等于 margin —— 小于就是压着字画,大于就是先前那种大片空白,一个数把两头都钉住了。

判据的覆盖面是审核挑出来的

上面这张表的后五条,有四条是代码审核之后才补的,而且每一条都对应一个 "实现改坏了却全绿"的实测:

改坏什么 补之前 后果
ink_canvas 不翻 y 全绿 全量审计 610 处违例
escape_boxes 不撑开 margin 全绿 全量 367070 条线的坐标指纹变了,而审计逐项一样
ray_exit 去掉"完全错开"那一刀 全绿 塞不下 859 → 860,没有判据红
字形表里 e/r/* 的墨迹、P 的宽度 全绿 判据只跑七个分子、覆盖 11 个字符,Br/Fe/Na/Mg 抄错没人管

最后一条的修法是换驱动源:从"七个手挑的 SMILES"改成遍历 118 号元素, 判据自己那张表里缺字符就当场 panic,逼着它整张抄完。这与本文件反复出现的 那条教训是同一件事 —— 手挑的样本只覆盖得到心里已有的那个模型

全量对比(基线 aeedc97)

—— 有标签在键上塞不下 1146 859
线端不压字(查到 / 违例) 17578 / 0 17584 / 0
环内双键(查到 / 违例) 12806 / 0 12807 / 0
写法无关 9 9
原子不重合 79 79
键角不过窄 180 180
干净 16191 16191
外部判官 496 一致 / 0 读反 496 一致 / 0 读反

"查到"两列各涨了几个:塞不下的键要跳过不查,塞不下的少了,能查的就多了。

一处没做的,要说清

外接盒没有跟着改。 half_w/half_h 仍是"前进宽度 + 名义字高"那个上界, bounds(画布留白)与 refine::radii(消冲突半径)仍用它。两件事:

  1. 横向上墨迹盒一定在外接盒之内(前进宽度含左右边距),判据断言了这一条。
  2. 纵向不然。 HgAgNpDy 的下伸部比名义字高多出 0.22 em, 那一截伸在外接盒之外 —— 也就是说 bounds 给的画布可能切掉一点点尾巴。 这是先前就有的账(外接盒一直只按 CAP/2 + 上标/下标 算),不是这轮引入 的;但现在有 ink 了,它第一次变得看得见

要合并成一个盒,得连 bounds 与消冲突半径一起改,那是「refine 的碰撞判定 改成偏心盒对盒」那件事的范围。这里如实记账,不假装已经做完。

弧法接进运行时:立项时那个前提是错的

arcs.rs 一直 #[cfg(test)] 门着,只当离线模板生成器的候选之一。任务写着 「接进运行时前要解决它自己的鸽笼」(两个锚点之间三条等长桥时,等张角弧 对给定锚距只有两个镜像解,三条挤两个必然重合)。

先量,再决定。

一、弧法在真实语料上能摆多少

177 个桥环体系:

摆得出来 132(74.6%)
摆不出来 · 弦跨不过去 22
摆不出来 · 鸽笼(原子叠一起) 23
摆不出来 · 没锚点 0
成功的里内部有自交的 3
成功的键长偏差 精确 0

唯二的例外是那两个二茂铁,偏差 0.73 / 0.93 —— 那是 η5 在平面上本来就做不到 的事(见上面二茂铁那一节),不是弧法的毛病。

二、对语料内的分子买到什么:0

弧法的解本来就参与模板竞选(rings.rs 的生成器把它 offer 进候选池), 赢了就存进表。177 个体系里 173 个查表命中;剩下 4 个全是那两个二茂铁, 而它们已经不走桥环路径了(hapto 修法)。

接线前后跑全量审计,输出逐字节相同

三、对语料外的新骨架买到什么:交叉腰斩,但取代基更挤

把表遮住(那正是"语料里没有的新骨架"的待遇),在弧法摆得出来的 132 个体系上:

弧法 松弛
内部有自交的体系 3 75
逐体系比谁交叉少 73 胜 1 胜 · 58 平

更要紧的是遮表跑全量审计(8831 分子 × 2 规范 × 30 种写法),两套代码各一遍:

只有松弛 弧法 + 松弛
硬性质总违例 258 256
原子不重合 77 75
其余九条(写法无关、键长全等、环内双键、楔形、不出画布、线端不压字…) 0 0
—— 其中有键交叉 260 130
—— 布局已退化的(交叉) 242 112
—— 骨架被摆成 180° 120 56
—— 有取代基挤到另一根键上 34 80

硬性质一条没破,交叉腰斩。但取代基挤压涨了 46 处 —— 那是弧法自己的代价, 不是"次序"的代价。 弧法的桥摆得漂亮(键长精确 1、几乎不自交),可它不知道 取代基要从哪伸出来;松弛歪归歪,却是在整个系统上下降的,顺带给取代基留了地方。

这一笔在语料内看不见(173/177 走查表),但在语料外照付不误。取它是因为 交叉减半 + 硬性质更干净压过了这一档;如实记着,不藏

复现:把 templates::lookup_withNone 分支直接改成返回 (None, Status::NotInTable)(整张表遮住),跑 cargo run -p omgkit-depict --release --example audit -- harness/corpus/large.smi 30, 再把 rings::relax 里那句 arcs::place 注释掉跑第二遍。

四、鸽笼不必先解

立项时的前提是错的:弧法摆不了时自己报 None,退回松弛就是了;而它摆得了 的那 74.6% 已经把松弛打得很惨。剩下的 45 个体系原样走松弛,一点没变差。

语料上零覆盖 —— 这条路径测不到

在弧法那一档插探针跑全量:命中 0 次。173/177 查表命中,剩下 4 个是二茂铁 而它们已被 hapto 减键摘出桥环分支。

所以"接线前后审计逐字节相同"证明的不是"改动无害",而是语料测不到它。 守着这条运行时路径的只有下面那三条判据(分子是手挑的)与上面那份遮表对比。 下次动 pick/solve_theta,语料和审计都不会红 —— 记住这一点。

次序是查表 → 弧法 → 松弛,不能颠倒

弧法只保证几何(键长精确 1、桥不自交);表里那一条是按整分子打分挑出来 的,它见过取代基往哪伸、标签占多大。实测把弧法提到查表之前:

表优先 弧法优先
其中有键交叉 48 70
有取代基挤到另一根键上 28 80
布局已退化的(交叉) 30 52

看着像"次序错了要付三倍取代基挤压",但那笔账不该记在次序头上 —— 遮表实测 「只有松弛 34 → 弧法+松弛 80」,同样是 80。+46 是弧法本身的代价,次序只是 让表替语料内的骨架挡住了它。次序真正买到的是交叉 70 → 48:表里那一条是按 整分子打分挑出来的,它见过取代基往哪伸、标签占多大;弧法只保证几何。

判据

判据 守什么 变异
the_runtime_reaches_for_the_arc_before_it_falls_back_to_relaxing 表遮住时运行时给的就是弧法那一套,且键长精确 1 去掉弧法那一档 → 红
the_table_wins_over_the_arc_when_it_has_an_answer 表命中时给的是表那一套 弧法抢在查表前 → 红
when_the_arc_cannot_do_it_the_runtime_falls_back_instead_of_giving_up 遮表之后金刚烷(鸽笼)照样有坐标 弧法失败后交空坐标 → 红
how_it_was_written_does_not_change_where_the_atoms_go(原有) 头号契约

三条都先断言前提(表确实遮住了/确实命中了、弧法确实摆得出来/确实摆不了、 表与弧法给的不是同一套),否则它们守的是别的东西。

第三条一开始是空过的:金刚烷与双环[2.2.2]辛烷都在表里,layout_local 走的是查表那一档,根本到不了弧法/松弛。审核实测:不遮表时把"弧法失败后退回 松弛"整个换成"交空坐标",六条判据全绿。加了遮表与前提断言才立住。

弧法赢了就不再与松弛比 —— 有意的

逐体系比是「73 胜 1 负 58 平」,那 1 负是白输的。不比是因为现成的 quality 只数 光骨架的自交,而这一路反复撞的就是"光骨架的分数不等于整分子的好坏"。真要挑 得对得按整分子打分 —— 那正是模板表离线在做的事,也正是查表排第一档的原因。

换来的是短路:弧法排在 relax 的查表之后、多起点松弛之前,表未命中时省掉 整轮松弛。实测遮表全量:弧法总耗时 1.56 ms、松弛 71.9 ms(便宜 46 倍), 其中 44.4 ms(61.7%) 原本是"弧法赢了、松弛白跑"的。

头号契约归零:最后一处在拼环的环心平局

写法无关的违例从 223 一路降到 9、3、2,最后这一处是 7879 —— 一个 Ni 的四齿 配合物,五个环共用同一个金属。两套规范都违,40/40 个图元全不同。

与前面几处都不同:分岔在布局

前面几个是消冲突(far_side 平局)或摆位(orient)。这一个先量:

距离多重集 19/210 项不同 —— 形状真的变了,不是姿态
键长 全部精确 1.0000 —— 不是松弛的退化布局。degraded 实测是空的,审计打的「布局已退化」是 !clean 的合并口径,这个分子进的其实是「有未解冲突」那一档
消冲突翻了几根键 0
布局后的形状指纹(FNV-1a 打在量化后的两两距离上) 两种写法不同 —— 布局阶段就已经分岔

再往里:SSSR 环感知完全一致(5 个环,按规范秩表示逐项相同),摆环的次序 也完全一致(同样的环、同样的顺序)。唯一的差别是拼环时沿键的那两个原子的 次序:

第 1 次 第 2 次 第 3 次 第 4 次
写法 A(按规范秩记,不是原子下标) (20,11) (11,20) (10,20) (20,10)
写法 B(同上) (11,20) (20,11) (20,10) (10,20)

每一次都正好反过来。

为什么交换会有后果

(u, v) 取自 shared[0], shared[1],而 shared 保留的是 r.atoms 的顺序 —— SSSR 的输出序,依赖存储序

交换 uv非平局时不改选边:两个候选环心跟着互换,而"取远离已放置 质心的那个"仍然选中同一个点,转向也自适应。

可一旦平局,两个候选心到质心等距,代码走 elsec2,而 c1/c2 正是 被交换换掉的那一对 —— 于是两种写法把新环拼到了相反的一侧

平局的几何条件是「质心落在键所在的那条直线上」,不是中垂线。 c1/c2 沿 法线对称,等距点集因此是过 mid 沿键方向的直线;中垂线上只有 mid一个 点等距。这一条先前写反了,审核用实测反证:7879 的第 1 次拼环质心恰在中垂线 上(距离 5.6e-17)而间隔是 1.376(离平局远得很),第 2 次质心恰在键所在 直线上而间隔是 0

四次拼环里只有一次是平局

实测四次的有符号投影绝对值依次是 1.3764 / 0 / 0.9346 / 0.3753(u,v) 确实 四次都反过来,但按上面那条,非平局时交换不改选边 —— 根因只有第 2 次那一处, 第 3、4 次拼歪是因为拼在一个已经分岔了的局部布局上(它们的质心已经差了 0.19 / 0.17,不是 1e-16)。先前写「四次全中」,不准确。

修法与结果

三件事,不是一件:

  1. 调用处把 (u, v)规范秩定序 —— 定死 c1/c2 谁是谁;
  2. 质心按规范秩累加 —— 它是一串浮点求和,次序一变末位就变;
  3. 有符号投影显式判平局(|s| < 1e-9),平局时取 c1,不进浮点比较

只做第 1 件不够:平局时数学上两边相等,胜负仍由舍入拍板。审核实测:拿 400 种 真置换改写 7879,那一次里 109 种(27%) 的间隔已经不是精确 0 了 —— 语料 过得去靠的是符号碰巧一致,不是构造保证。

非平局那一侧不在刀锋上:全量 5398 次拼环里最小的非零间隔是 0.167,比浮点 噪声高十五个量级,中间一次都没落进 1e-6 以内。

写法无关(头号契约) 2 0
其余每一档 一处没动
硬性质其余九条 一处没动
外部判官 496 / 0 496 / 0

判据三条,两条守得住

判据 守什么 变异
the_ring_layout_does_not_care_how_sssr_wrote_the_cycles 把每个环的序列转 k 步、按需反向(SSSR 输出序仅有的两个自由度),layout_local 输出逐点相同 不定序 →
a_tie_between_the_two_ring_centres_is_decided_by_a_rule_not_by_rounding 质心精确落在 mid 上时取 c1 退回浮点比较 →
a_mirror_symmetric_molecule_…(加 7879 那一对) 端到端 不定序 →

第一条不经过 refine / orient / render,断言直接指着根因。

第 2 件事(质心按规范秩累加)没有判据。 全量语料上把它换回按存储序累加, 147 条判据全绿、审计一处不动 —— 语料里没有「累加次序真的翻了符号」的例子。 它是构造性的防御,不是量到的收益,如实记着。

这条线的总账

时点 写法无关违例
搅拌器换成真置换之前 134
换真置换、写法数提到 30 223
…一路修到本轮开始 9
规范秩换成"类 + 规范次序" 3
far_side 的平局 2
拼环的环心平局 0

"违例从 223 降到 0"这句话要连着读:223 那个数本身是判据变强之后才看得见的 (搅拌器 10.85% 的改写原样返回、写法数只有 5 时漏报 10%)。数字先涨后降,涨的 那几次不是画得更差了。

注释写着的平局打破,代码里没有 —— 6457 那一处

换秩之后头号契约剩 3 处。查其中的 6457(一个大稠环,只在 ACS 下违例)。

分岔不在布局

「形状相同、摆位不同」。先量姿态:两种写法的最终坐标形状全等,但差 12°(ACS);ChemDraw 下差 0° —— 与"只有 ACS 违例"对得上。

12° 不是 30° 的倍数,所以 orient 的 24 个姿态(12 个 30° 旋转 × 镜像)归一 不掉它。往前追:

布局后    写法 A −14.6099°   写法 B −14.6099°    ← 完全一致
消冲突后  写法 A −91.5135°   写法 B −76.4865°    ← 差 15.03°

布局是干净的,分岔出在消冲突。 而同一分子 ACS 违例、CD 不违例也解释得通: 规范只影响标签尺寸→碰撞,CD 的标签小,消冲突根本没动手。

far_side 的平局

消冲突的算子是反射(绕键轴把一侧翻过去)。候选键已经按规范秩排好了,所以 次序没问题;问题在翻哪一侧:

// 取更少的那一侧。平局(两侧一样多)时按**这一侧最小的规范秩**定 ——
// 拿存储下标定就又把写法依赖引回来了。
if out.len() > other.len() && !other.is_empty() {
    return Some(other);
}
Some(out)          // ← 平局时返回 out,而 out 是从 bd.end 走出来的

注释描述了一个没有实现的平局打破。 begin/end 谁在前正是书写痕迹。

为什么 orient 兜不住 —— 一次翻错侧就够了

绕同一根轴翻这侧还是那侧,两套坐标差一次整体反射。直觉上 orient 该能归一 它 —— 它的 24 个候选姿态里有镜像。但那是 D₁₂,镜面只落在 15° 的整数倍上: 绕角度 φ 的轴做整体反射,归一之后的残差是 2φ mod 30°。轴不在 15° 网格上, 残差就消不掉。

实测(在 orient 里临时加判据,拿阿司匹林量,量完删掉):

15° 30° 45° 22.5° 84° 96°
残差 0 0 0 0 16° 15° 12° 18°

6457 正是这一支:两种写法各只翻了 1 根键,消冲突后一个 −91.5135°、一个 −76.4865° —— 关于 −84.0° 对称,是镜像不是旋转;2×(−84) ≡ 12 (mod 30), 最终图上差的正是 12°。

这一节先前写的是「一次反射 orient 归得掉,要两次绕不同轴的反射才归不掉」。 错的,而且会让人得出"单次翻错侧无害"的结论 —— 而稠环的布局姿态本来就 不在 30° 网格上(这里是 −14.6099°),这不是罕见情形。审核实测纠正的。

修法与代价

把注释说的那一步补上:平局按两侧最小规范秩挑。

写法无关 3 2
其余每一档 一处没动
硬性质十条 一处没动
外部判官 496 / 0 496 / 0

判据两条,一条整图一条单元

判据 守什么
a_mirror_symmetric_molecule_…(加进 6457 那一对写法) 端到端:整份图元一致
which_side_gets_flipped_does_not_depend_on_how_it_was_written 直接验 far_side:每根键映成「两端规范秩 → 返回那一侧的秩集合」,不同写法必须给出同一张表

两条变异都红(平局退回 out)。

先前这里写着「单元判据写不成:小分子上构造不出那种情形」。不成立 —— 正丁烷就够(CCCC vs C(CC)C,中间那根键两边各两个碳),语料里还有四个 现成的(4881、4824、2631、4719)。审核把它写出来了,变异当场红。

补这条单元判据要紧,因为整图那条的灵敏度是量得出来的:全量 19 万张图里 far_side 遇到平局 2457 次、选择变了 1514 次,而最终只有 4 张图变。 同一分子换 ChemDraw 规范就根本不翻键。标签尺寸、模板表、orient 任何一处改动 都可能把 6457 悄悄挪出平局区,那条判据从此空过。

二茂铁:η5 配位被当成 5 根 σ 键,环感知就塌了

「骨架指纹算不出来」这一档一直是 8 处(0.05%),注释写着"补语料没用,要查那个 分子本身"。查了:8 处全出自两个二茂铁

病因

SMILES 把 η5 配位写成 5 根独立的 σ 键([Fe]23456789),于是 Fe 的度数是 10。环感知照单全收,吐出 9 到 10 个三元环(Fe + 环上相邻两个碳)—— 全是这个建模方式的假象。整个体系被当成一个巨大的桥环系统,而骨架全碳化之后 度数超 4,sanitize 过不去,指纹自然算不出来。

画出来是两个 Cp 环叠在一起,ChemDraw 规范下 8 处交叉

范围先量清楚

「金属 M 与某个环有 ≥3 根键」——环要在摘掉 M 的键之后感知,那才是配体 自己的环。全量语料:8831 个分子里只有 2 个,都是二茂铁,都是 η5 × 2。

范围这么小,所以做的也小。

做法:布局每个 (金属, 环) 只留一根代表键

摘掉多余的那些键之后:

  • Cp 成了普通五元环 —— 正五边形,环内键长精确为 1
  • 二茂铁的 Fe 只剩两根键(每个 Cp 一根),成了两个五边形之间的连接原子

不是教科书那种 180° 的夹心式 —— 实测 Fe 指向两个环心的夹角正好 120° (到两个环心都是 1.85),是个普通的 sp² 式连接原子,图上看是弯的茂金属。 先前这里写"夹心式",言过其实,审核挑出来的。

只动布局。 渲染仍拿原分子,10 根键一根不少地画出来。这与 as_plain_bonds(给布局做一份把配位键当单键的副本)是同一个套路。

只数键数不够,还要连续。 见下面「审核挖出来的两条」。

二茂铁
退化 2 2(换了一种,见下)
未解冲突 2 0
键交叉 8 4
骨架指纹算不出来
看上去 两个环叠在一起 两个干净的五边形 + 居中的 Fe

两处"如实说"

一、交叉要拿原分子重算。 消冲突跑在摘细过的副本上,看不见被摘掉的那些 η5 键 —— 而它们照样会画出来(金属在环外,连到远端环原子的线必然穿过环)。 不补这一步报的是 0 处交叉,而图上明明有 4 处

二、这仍然是退化的,新增一档 HaptoCoordination 那 10 根 Fe–C 键在三维 里等长(金属在环平面正上方),在平面上做不到。少了这一笔,二茂铁会被算成 "干净",于是硬性质 键长全等 开始查它并当场破 4 处(实测)。

全量对照

—— 其中骨架指纹算不出来 8 0
—— 有标签在键上塞不下 853 852
其余每一档 一处没动
硬性质十条 一处没动
外部判官 496 / 0 496 / 0

审核挖出来的两条,都是实质缺陷

一、wedges 的下标与被画的分子对不上。 摘键是删键,键下标整体前移,而 assign_wedges 拿的是摘细副本 —— Depiction::wedges 的公开契约当场破: 二茂铁实测 wedges.len() 是 12 而键数 20,wedges[4] 描述的其实是第 5 根键, 第 12~19 根根本没有条目。

当时看不出来只因为这两个二茂铁没有立体中心(楔形全是 None),而 render::scenewedges.get(bi).unwrap_or_default() 静默兜底; examples/dump_molblock.rs 用的是 d.wedges[bi],只要哪个 η 配合物带一个 立体中心就直接越界 panic

修法:逐原子/逐键的东西一律拿摘细前的那一份(radiifix_cis_transassign_wedges),只有 layout_all / relieve 拿摘细副本。并在 scene 里补 一条 wedges.len() == num_bonds 的断言 —— 那里先前只查了 coords,缺口正好 开在这儿。

二、只数键数会把大环螯合物一起收进来。 卟啉、酞菁、环多胺那一类,摘掉 金属之后照样是个环,而金属对它有 4 根键 —— 全部满足 >= 3

审核拿真实化合物 Ni(环四胺)[Ni]123N4CCN1CCCN2CCN3CCC4 实测:

基线 只数键数
键交叉 0 5
Ni 到四个 N 的距离 0.95 / 0.96 / 1.22 / 1.24 1.00 / 3.51 / 4.34 / 5.49
报的退化 桥环 η 配位(化学上不成立,cyclam 是 σ 给体)

一张本来画对的图被改坏了。 而语料里一个大环配合物都没有(实测 147 个含 金属且度 ≥3 的分子里,"金属打进配体环的最大键数"只有 0、1、5 三档),所以 全量指标看不见这一条 —— 它是拿真实化合物试出来的,不是量出来的

修法:要求金属打进的那些原子在环上首尾相接。η5 的 Cp 是 5 个连续的碳, 而 cyclam 的 4 个 N 之间隔着 2~3 个碳。判据 contiguity_is_what_separates_a_sandwich_from_a_chelate 钉纯函数,a_macrocyclic_chelate_is_not_mistaken_for_a_sandwich 钉那个反例。

还有一处记反了

先前这里写「代表键按规范秩挑没有判据,换成存储下标全量一处不变」。说反了 —— 审核实测换成存储下标之后,全量写法无关 3 → 7(多出来的 4 处正是两个 二茂铁 × 两套规范),塞不下 852 → 853。原因也写错了:「Cp 五重对称所以挂哪个 碳都全等」对未取代的二茂铁成立,对取代的不成立(形状指纹真的变了)。

单元判据当时看不见,是因为它只比了一种别的写法(规范式回写),而那一种恰好 也选中同一根键。这是判据采样不足,不是"无从验证"。

一处真的没有判据,如实记

>= 3 这个阈值:把口径放到 >= 2 重扫 8831 个分子,"金属与配体环有正好 2 根键"的情形是 0 个。它是设计取舍,不是数据逼出来的。

顺带记一笔立项时差点犯的错:「螯合物不该被摘」那条判据,一开始挑的是三乙二胺 合钴 —— 可它摘掉金属的键之后根本没有环(环只存在于经过金属的路径上), 所以阈值取几都检测不到,判据是空过的。现在那条守的是别的东西(摘金属再 感知这个做法本身),而"不误伤大环"由 Ni(环四胺)那条单独守。

头号契约剩下那 9 处:根子在规范秩的深层平局是任取的

写法无关的违例从 223 一路降到 9 之后卡住了。这一节把那 9 处逐个查清,并修掉 其中 6 处 —— 修的地方不在出图这一层

先把 9 处按实际现象重新归类

不照搬旧标签。9 处来自 5 个分子:

语料行 现象 次数
573 坐标完全相同,楔形/虚楔互换 2
2553 坐标多重集相同,可线连在不同的点对之间 2
994 形状相同、摆位不同(顺反) 2
6457 形状相同、摆位不同 1
7879 形状就变了(Ni 配合物,已退化) 2

先验前提:这五对改写是不是真的同一个分子?拿 RDKit 逐对核过规范式 —— 五对全是。所以 9 处都是真违例,不是判据的锅。

573:两张图都对,而挑哪张由存储序说了算

坐标一模一样、楔形却互换,这在几何上不该发生 —— 除非两张图都对。查下去:

这个分子是非手性的。 1-乙炔基-4-苯基环己醇有一个穿过 C1、C4 的镜面, 它的立体信息只是环上的顺反关系。整套楔形全局对调之后画出来的还是它。

而挑哪一套,现在由 canonical_ranks 说了算 —— 那个函数自己的模块文档写着 「更深层次的并列仍是任取」:break_all_ties 把每个还没分开的格里存储序 最靠前的那个原子劈出去。C1 的两条环支路构造上等价、构型上相反,1-WL 细化分不开它们(立体宇称是相对邻居的等价类算的,镜面下不变),于是任取 一头。

但它们并不真的等价,这一点是下面那个修法能成立的前提。把两种写法各按自己的 canonical_ranks 写成规范风格的串:

写法 1: C#C[C@@]1(CC[C@@H](CC1)c1ccccc1)O
写法 2: C#C[C@] 1(CC[C@H] (CC1)c1ccccc1)O

骨架逐字相同,每一个立体标记都翻了 —— 两套标号差的是一个反自同构 (镜面),不是自同构。若真是自同构下等价,两串会完全一样,那么"遍历起点取字典 序最小"也同样分不开,换秩就没有意义了。正因为不等价,取最小串消掉了这个自由度。

走过的一条弯路:直接翻转楔形

先试的是"把整套楔形翻过来,若描述的还是同一个分子,就由规范秩挑一套"。 写法无关确实 9 → 7,但外部判官从 0 涨到 4(楔形可读 同时 0 → 4)。

原因是它把矛盾挪了个地方而没有消掉:对非手性分子,"写法无关"与"逐原子反读 一致"不可能同时成立 —— 逐原子的标记本来就是相对存储序的。当场否掉。

正解:把任取的那一层换掉,别动出图

canonical_ranks 只有本 crate 和 omgkit-io 自己的测试在用 —— canonical_smiles 不用它(它另起一个 Partition,取遍起点求最小串)。 所以换掉它对规范式没有影响。

第一版换成纯粹的规范 SMILES 输出次序(Written::atom_order)。头号契约降到 2, 可键交叉从 50 涨到 78,重跑模板生成器也只收回到 70。查下去是语义不对:

是什么 布局要的
symmetry_classes 1-WL 细化的等价类 ✅ 类编号反映结构角色
atom_order 规范 SMILES 的 DFS 输出序 ❌ 唯一,但结构上任意

布局的启发式把"秩小"当"重要",而 DFS 序不是那个意思。

所以分两级:主键仍是细化出来的类(结构语义原样保留),只有类内那点任取 被换成规范次序

order.sort_by_key(|a| (classes[a], canonical_position[a]));

全量对照

8831 分子 × 2 规范 × 30 种写法。第二、三列都用旧表跑,好把"换秩"这一件事 单独看清;最后一列是换秩之后重跑生成器的最终状态。

canonical_ranks 纯输出次序 类 + 次序 最终(+重生成表)
写法无关(头号契约) 9 2 3 3
其中有键交叉 50 78 48 48
干净 16191 16169 16192 16192
有未解冲突 1131 1150 1130 1130
标签塞不下 859 846 858 853
骨架原子被摆成 180° 102 102 124
原子不重合 79 86 79 79
键角不过窄 180 / 16191 181 / 16192 181 / 16192
硬性质其余八条 基准 基准 一处没动 一处没动
外部判官 496 / 0 496 / 0 496 / 0 496 / 0

换秩这一件事本身几乎没有代价 —— 第三列相对第一列,每一档都持平或略好, 只有 键角不过窄 从 180 变成 181。

那一处不是新缺陷,是判据的适用范围变大了:语料第 2902 行在 ACS 下从 「未解冲突 2」变成了「干净」,于是首次进入这条判据(它只查布局没退化的), 把自己那个 60° 夹角一起带了进来 —— 分母也同步从 16191 变成 16192。 先前这里写的是「硬性质其余九条一处没动」,不准确,是审核挑出来的。

表要不要重生成,是另一回事,而且有代价。 秩一换,生成器的产物就变了;不重 生成的话「重跑逐字节相同」这条验收当场作废,所以重生成了。代价是骨架 180° 从 102 涨到 124(0.58% → 0.70%),换来塞不下 −5。生成器的排序键里 180° 排在 最后一档,它按自己的次序挑,corpus 层面的 180° 就跟着涨了 —— 如实记在这儿, 不假装没有。要压回去得动生成器的排序键,那是另一件事(上一次调它的实测记在 「模板生成器改成按整分子打分」那一节)。

修掉的是 573、2553、994 这三个分子(6 处)。剩 3 处:6457(ACS 下的摆位)与 7879(Ni 配合物,已退化)。

还没做的那一层

真正干净的做法是让 canonical_ranksbreak_all_ties 本身规范化 —— 标准办法是每个平局逐个试、取最小证书。那是 omgkit-io 规范化核心的算法改动, 要连它自己那套差分一起验,不在本轮范围内。本轮做的是在出图这一侧不依赖那层 任取,代价为零。

消冲突为什么只能用圆:两次实测把「偏心盒对盒」这条路封死了

refine::radii 给每个原子一个各向同性的碰撞半径 hypot(half_w, half_h), 而标签盒明明是个偏心的矩形。代码里挂了很久一句"正解是碰撞判定直接用偏心的 盒对盒……记在这里等着做"。这一节把它了结:那不是没空做,是流水线不允许。

一、refine 那个坐标系里的"水平"不是最终的水平

标签在画布上永远是轴对齐的。可 generate 的顺序是

layout → fix_cis_trans → refine::relieve → orient::canonicalise → assign_wedges

canonicalise30° 的倍数里挑姿态(12 个旋转 × 镜不镜像)。也就是说 refine 跑的时候还不知道整张图最后要转多少度。实测语料前 300 个分子 × 2 规范 共 600 次:

最终转角 次数
90° 的倍数(k=0,3,6,9) 228 38%
斜的(其余 8 个 k) 372 62%

六成情形下,在 refine 里摆一个轴对齐矩形,方向就是错的。 圆是"对旋转角 一无所知"时唯一诚实的形状 —— 它正是矩形在所有朝向上的上界。这里的各向同性 不是偷懒。

二、把摆正提前?代价是头号契约

唯一的出路是让消冲突在最终坐标系里跑,也就是把 canonicalise 挪到 refine::relieve 之前。试了,全量实测:

现状 摆正提前
写法无关 9 21(+12)
干净 16191 16191
有未解冲突 1131 1131
其中有键交叉 50 50
塞不下 859 859

头号契约多出 12 处,其余一档没动。 多出来的全是"形状相同、摆位不同" —— 摆正一旦不在最后一步,消冲突之后就没有东西再把姿态归位,同一个分子的两种 写法会停在两个姿态上。

而且注意:这 12 处是光挪顺序的代价,盒模型连写都还没写。所以这条路的账是 "先付 12 处头号契约,才换得到试盒模型的资格"。

三、结论

在"摆正必须是最后一步"这个前提下,圆就是正解,不是权宜。这条前提本身是 头号契约要求的,不打算动。

顺带把另一个已经量过的选项也记在这儿,免得重查:改成"原子到盒最远那个角" (各向同性地取最大)—— 长边诚实了、短边跟着过估,干净率 91.6% → 89.3%, 未解冲突 +401。亏的。

模板生成器改成按整分子打分:交叉 62 → 40

前面几节反复撞同一堵墙:光骨架的自交数不等于整分子的交叉数。弧法把 30 条 模板的键长做成精确的 1、骨架自交一处没涨,整分子键交叉却从 62 涨到 74。

为什么骨架级的指标结构上就看不见

审核实测了一组数(短名单 8~16,187 个真实分子 × 2 规范):

第二档换成 整分子交叉 未解冲突
键长偏差(原来的) 92 329
最近原子距离 92 339
靠太近的原子对数 92 337
骨架共线原子数 92 333
回转半径 80 300
整分子打分 60 226

根本原因在双环[2.2.2]辛烷上看得最清楚。它的 8 个候选骨架 Quality 前两档 完全相同(自交 0、偏差 0.203),造成的整分子交叉却是 2 到 20 —— 而表里存的 正是最差的那个(20 处交叉、38 处未解冲突)。

更要命的是其中三个候选连两两距离多重集都相同 —— 同一个形状,差别只在哪个 骨架原子落在哪个位置,也就是自同构的选取任何骨架级的几何量对自同构都是 不变的,换什么代理指标都看不见。只能拿真实分子去问。

我原本的方案门槛量错了对象

方案里写"先量有没有'骨架自交更少但整分子更差'的对立情形,找不到就不做"。 审核实测:严格对立全语料只有 7 对、集中在 1 条骨架,只贡献 32 处交叉降幅 里的 2 处94% 的收益方案根本没打算去量。

真正的收益在平局:同一自交档内整分子交叉不同的骨架 7/54,未解冲突不同的 28/54。而且同一档内"偏差更小"与"整分子更好"的一致率只有 52.1% —— 原来的第二档基本是抛硬币。

按原方案的门槛,这个改动差点被自己否掉。

怎么做的

两阶段。 阶段一用现在的搜索选出一份短名单(按 Quality 排序、按量化 坐标序列去重、留前 SHORTLIST 个);阶段二拿真实分子给名单打分:

(整分子交叉, 未解冲突, 原子重合, 标签塞不下, 骨架 180°)  ← 定胜负
(自交数, 键长偏差, 量化坐标序列)                        ← 平局兜底

后两档是审核实测补上的:只按前三档挑,骨架 180° 会从 240 涨到 250、标签 塞不下从 49 涨到 61;补上之后前三档一处不掉,这两档反而好过现状。

SHORTLIST 取 16 而不是 8:取 8 时有 3 条骨架选中最后一名(边界还在起约束 作用),取 16 之后选中名次一路用到 #8/#9/#11/#13/#14。打分很便宜(5920 次 generate,合计几秒),所以也没做采样,每个骨架的分子全用

但 16 不是"被证明够用",而是碰巧落得好 —— 这一点要写清楚。 审核把它调到 48 跑了一遍:目标侧确实继续变好(逐骨架交叉总和 34→30,金刚烷 8→4), 而全量审计的键交叉反而从 40 涨到 44。原因见下一节。

打分的量必须与报告的量是同一个

上面那个"目标变好、KPI 变坏"不是偶然,是口径错位:打分先前求的是 185 个 分子上的总数,而审计报的是 17662 张图里的发生率。把总数从 8 压到 4, 完全可能是把 2 张各 4 处交叉的图变成 4 张各 1 处 —— 总数减半而发生率翻倍。

改成数"这张图有没有"(!d.crossings.is_empty())而不是"有几处"之后,键交叉 又从 40 降到 38

同一类错还有两处,都是审核实测出来的:

先前 改成 为什么
原子重合 < 1e-6 < 0.05 审计的 TOL 就是 0.05。严了五万倍 —— 864 个候选里只有 1 个非零,这一档从没决定过任何一次选择,而审计报的 79 处违例它一个都看不见
骨架 180° is_collinear 加 sp 过滤 审计用的是 accidental_collinear,多一道过滤(有三键或两根双键的本来就该 180°)。裸版实测命中 228 次,其中 74 次是真正的 sp —— 三成在惩罚正确的丙二烯几何。这一档决定了 54 条骨架里 8 条的选择

"优化的量必须与报告的量是同一个" —— 这条比调参数重要得多。

循环依赖:穿参数,不用全局状态

generate 会查模板表,而表正是生成器在生成的东西。破法是给 templates::lookup_withgenerate_with 加一个 Override 参数,一路穿到底。

方案里原本写的是 cfg(test) + thread_local否掉了:它有隐藏状态、panic 之后覆盖会留在线程里、将来 generate 内部要是并行就坏。穿参数多改四个 pub(crate) 签名,但没有这些问题,而且看得见。

纯函数性做成了结构保证:lookup_with 一旦拿到覆盖,整张表都被遮住 —— 骨架对不上就直接报 NotInTable,而不是回落去读 TABLE。这样"打分时读到正在 生成的那张表"在结构上就不可能发生,不再依赖"语料里碰巧没有这种分子"。

头一版写的是"打分分子只收恰好含一个退化环系的",注释还说"实测目前一个都没有" —— 那句是错的:实测挡掉了 3 个分子(两个镍配合物、一个汞配合物),而且它们 重复的都是同一条骨架,覆盖会同时罩住,纯函数性一点不受威胁。现在按"退化 骨架只有一种"收,那 3 个分子也用上了。

结果

large.smi(17662 分子×规范) 按骨架挑 按整分子挑
—— 其中有键交叉 62 50
—— 布局已退化的 44 32
—— 两端都不是端基的 38 32
—— 涉及端基键的 24 18
—— 有标签在键上塞不下 1148 1146
—— 有取代基挤到另一根键上 ~30 28
—— 有骨架原子被摆成 180° 184 102
—— 有立体中心的取代基挤在一侧 2 4(变差)
硬性质十条 / 写法无关 全同 全同(9)
外部判官 496 / 0 496 / 0

那一处变差是 4/17662 = 0.02%,而且它不在打分元组里,是碰运气动的 —— 审核把短名单调到 48 时它仍然是 4,对"优化得多狠"不敏感。没为它加第六档。

代价要如实说:骨架自交总数从 8 涨到 9(6 条变 7 条)。多一处骨架自交换掉 22 处整分子交叉 —— 这恰恰证明骨架自交是错的代理指标,所以 the_stored_coordinates_do_not_cross_more_than_they_used_to 的上界跟着新口径 走,文档里也写明它不是优化目标。

生成器耗时约 5 分钟,可复现性照旧:装上新表重跑,输出逐字节相同;TABLE 清空从零重跑,输出仍逐字节相同

樟脑变差了 —— 指标一个都没报,是看图才发现的;修的办法是把它变成指标

改完之后我只看指标就交了工。该看图。 看了才发现樟脑坏了:

旧表 新表
未解冲突 1 2
键交叉 0 0
降冰片烷模板的键长偏差 0.254 0.000

新模板的键长精确为 1,而降冰片烷的"精确"解把桥头碳压得太靠近 C2–C3 那条边, 偕二甲基的两根键塌到了一起。图上那两个甲基几乎重合,而 原子不重合 (容差 0.05)够不着,键交叉 也报不出来 —— 重叠共线的键不算"交叉"

查下去,几件事都不是我一开始猜的:

  • 不是打分瞎了。 樟脑就在打分集里(bridged.smi 第 42 行),打分器看见了 它、仍然选了这个模板 —— 因为对同骨架的另外 37 个分子更好。
  • 也不是"只数发生率"的锅。 我据此加了一档"总数当平局兜底",重跑之后 54 条模板一条都没变 —— 发生率那一级已经严格分出胜负,总数根本没机会 兜底。那个改动是空操作,基于错误诊断,撤了
  • 全语料的总数(不是发生率)也确认新表更好:未解冲突 3116 → 3027, 键交叉 150 → 100

不是局部代价,是一整类没人看的缺陷

顺着樟脑往下量,全语料有多少处取代基被挤到离另一根键 < 15°:

分子数 处数
旧表(bb02ea5) 15 62
按整分子打分之后 38 156

翻了一倍多,而一条指标都没报。 原因很具体:未解冲突原子间距离判, 挤成 6° 的两个原子相距 0.10 个键长,够不着阈值;键角不过窄 只查度数 ≤ 3 的 原子;键交叉 更看不见 —— 两根几乎重合的键不相交。

樟脑那个偕二甲基桥头碳的实测几何(度 4):

246°  环键        夹角   6°
252°  甲基        夹角  96°
348°  甲基        夹角   6°
354°  环键        夹角 252°   ← 一整片空的

打点查到 allocate 给的是对的(318°/42°,落在 252° 的大空隙里),是 free_direction 躲让时把它们各挪了 −90°,贴到了环键上 —— 因为新模板把桥头碳 画在了六元环里面,外侧确实没有空间。

修法:加一档指标,并且排在交叉前面

审计里新增 —— 有取代基挤到另一根键上,生成器的打分元组里也加一档。 而且它排在键交叉前面 —— 这与本库一贯的次序不同,理由是:

  • 共线可以补符号:渲染会给共线的骨架碳补一个元素符号,读者还看得见;
  • 交叉会让人读错;
  • 而挤压是"读错"那一类,且没有任何补救 —— 那个原子在图上彻底消失。

三种次序全量实测:

排序 交叉 挤压 共线 180°
旧表 62 ~30 184
交叉优先 38 74 72
挤压优先(采用) 50 28 102

挤压优先在三项上全面好过旧表;相对"交叉优先"是用 +12 处交叉、+30 处共线 换掉 46 处挤压。樟脑回到 未解冲突1,偕二甲基两根都看得见了。

这一整节的起点是"看了一眼图"。 指标漏掉它有具体机制,但那不是理由 —— 看图看出来的东西,下一步就该变成指标,否则下次还是只能靠眼睛。

看图这件事

这一轮我改了 54 条模板、影响整个语料,却只报指标没看图 —— 而且更糟: 交工前那批图是在改打分口径之前生成的,根本不是最终结果的图。是用户点出来 之后才回去看的,一看就发现了樟脑。

指标漏掉它有具体的机制(见上:重叠共线的键不算交叉、0.05 的容差够不着), 但那不是理由 —— 有一整套指标恰恰意味着"指标之外的东西只能靠眼睛"。 改完模板/布局/渲染之后必须自己把画廊过一遍,尤其是桥环那几个 (morphine、atropine、camphor、penicillin-G)。

这一轮看下来:atropine 比 RDKit 还整齐、penicillin-G 好、morphine 干净、 camphor 坏。四个里三个好一个坏 —— 而那一个,不看就永远不知道。

新出现的耦合,记在这里

打分分子的分布很偏:54 条骨架里 35 条只有一个打分分子,另有 9 条的 16 个 候选分数完全相同(打分档在那里是惰性的,退回 Quality)。也就是说,表现在对 单条语料行敏感了 —— 删掉 large.smi 里一行就可能翻掉一条模板。这是这次 改动引入的新耦合,不是缺陷,但重跑核对时要知道。

另外打分对 Style::ALL(两套规范)求和,将来加第三套规范,整张表会重写

判据踩的两个坑

一、自己编的分子验不出东西。 头一版我给判据写了四个简单取代的双环[2.2.2] 辛烷,结果按 Quality 排第一的候选就已经是 0 交叉 —— 判据自己的守卫当场拦下。 换成 large.smi 里真实的 8 个(挂着大取代基的),候选之间才分得出高下。

二、判据自己重写一遍选择逻辑,就守不住选择。 头一版判据自己算分数、自己 比较,于是把整分子分数从排序键里去掉,它照样是绿的。把选择抽成 pick_best 让判据去调,三条变异(去掉整分子分数、打分恒为 0、短名单只留 1 个) 才逐条打红。

那 8 处写法无关违例:一半是求和次序,一半还没修

上一节留了个尾巴:弧法表把全量的写法无关违例从 9 推到 17。逐个查完了。

先纠正一件事:我的探针指纹错了

头一版探针比图元时没把线段两端排序,于是"从 A 画到 B"和"从 B 画到 A"被当成 不同的图元,一口气报出十几处假差异。审计的 fingerprint 早就归一化了 (if x <= y),是我的探针没跟上。查这类问题,第一件事是让探针与判据同口径

一半:1e-16 的求和噪声,被 3 位小数的指纹放大

3413、2546、3485 三个分子:按规范秩配对之后,两种写法的最大坐标差是 4.4e-16 和 2.2e-16 —— 纯浮点噪声。而审计的图元指纹取 3 位小数,坐标恰好落在 59.9615 那样的边界上,一边进 59.962、另一边进 59.961,当场报违例。

根子在 orient::canonicalise 的质心:

let c = coords.iter().fold(Point2::ORIGIN, |s, p| s + *p) * (1.0 / n);

coords原子下标排,累加次序就是存储序;浮点加法不满足结合律,两种写法 算出的质心差最后一位,整张图跟着平移那么多。改成按规范秩累加,三例全同。

980 那一例也跟着好了,而它的坐标差是 2.6 个单位 —— 不是噪声。机制是放大: 1e-16 的质心偏移让 orient 的 24 个候选姿态里换了一个胜出,于是差出肉眼可见的 摆位。噪声不会一直是噪声,下游有离散选择的地方它就会被放大成结构性差异。

这个坑一直藏着,是模板换成几何求解(键长精确为 1、坐标大量落在半整数上)之后 才浮出来的 —— 精确的几何把浮点噪声推到了四舍五入的边界上

另一半:一个镍配合物 —— 环系统的顺序是存储序

973/974(同一个分子的两种写法,×2 规范 = 4 处)是五配位的镍、挂着三条一模 一样的配体。逐阶段打指纹:

阶段 两种写法的最大坐标差
layout_all 之后(镍那个分量) 5.29
装箱之后 5.29
顺反校正之后 5.29
orient 之后 10.61

分岔在环系统布局本身,而且是 5.29 个单位的真实差异,不是噪声。 三条配体 都命中同一个模板(查表 Hit ×3),所以问题在"怎么把它们挂到镍上"。

查下去:镍是割点(双连通分解把它切开),于是这不是一个环系统,而是三个 各含镍的 12 原子系统layout_componentof_atom 把"这个原子属于哪几个 系统"堆成一个 Vec,堆的顺序就是 fused_ring_systems 的返回顺序 —— 双连通分解的遍历顺序,也就是存储序。实测两种写法:

写法 A:环系顺序(按最小规范秩)  5, 3, 4
写法 B:                        4, 5, 3

三条螯合环长得一模一样,所以谁先摆决定了它们绕着镍怎么分布,摆出来差 5.29 个单位。平时看不出来,是因为几个环系统通常长得不一样,顺序错了也还能靠 "背离已放部分"那条挑回来。

改法:layout_all 里把 systems_all环系原子的规范秩有序多重集排序。 系统之间原子集互不相同,所以这是全序,不留平局。改完那 4 处全没了。

判据 the_ring_systems_are_consumed_in_canonical_order 验的是顺序本身, 不看画出来的图 —— 所以它与模板表无关,换表不会让它空过。变异验证:去掉排序 → 红。这一点很要紧:前面两处同类修复(place_at 的质心、settle 的定序) 恰恰因为只能从画出来的图去验,才造不出会红的判据。

顺带修的两处,如实说:没有判据守着

同一类的求和次序问题还有两处,都改了,但都造不出会红的判据:

  • rings::place_at 的质心(local.values() 是存储序)—— 质心末位的差被后面 (c - a).normalized() 的归一化吸收了,输出逐位相同。
  • rings::relax_fromsettlebonded(键的存储序,力按这个次序累加) —— 400 步迭代里末位差别既可能放大也可能被吃掉,造不出稳定样本。

试过为它们写判据,在打不打补丁下都是绿的 —— 空过的判据不留,删了。留着这两处 修改是因为"顺序必须与写法无关"这条本身成立,不是因为量到了收益。 orient 那处有判据(shuffling_the_storage_order_does_not_move_the_picture, 逐位比、不给容差 —— 1e-12 的容差量不到,那样写也是空过的),因为它的质心 直接进坐标,中间没有归一化。

当前状态

四处修复(orient 的质心、place_at 的质心、settle 的定序、环系统的顺序) 在现在这张表上是逐字节零变化(全量审计与改动前逐行相同,外部判官 496/0 不变)—— 它们只在坐标落到四舍五入边界、或几个环系统长得一样时才起作用。

装上弧法表的话:写法无关 17 → 9,与不用弧法表的基线完全持平。 那 8 处全查清了。

弧法表仍然没发,剩下最后一笔账:它让整分子的键交叉从 62 涨到 74。 模板优化的是光骨架,它不知道取代基 —— 这条 README 早就记过,这次又验证了一遍。 要把它变成干净的赢,得让离线生成器按整分子打分(把语料里用这个骨架的真实 分子跑一遍 generate 数交叉/冲突),而不是按光骨架。那是下一步。

桥环的方法本身是错的:随机重启抽奖 → 几何求解

前面几节一直在同一个方法里调参数(补语料、加预算、去掉提前退出、加几何阈值)。 这轮承认:方法本身错了。

rings::relax = 5 个随机初值 + 弹簧下降 + 按 Quality 挑最好的。三个根本毛病:

  1. 它不解问题,它抽奖。 morphine 的 0 交叉解要搜 22.4 万次才撞上;立方烷、 棱晶烷等 5 条骨架跑 200 万次仍然到不了。
  2. 键长和键角没有任何保证。 松弛只把键长"拉向"1,退化布局实测偏差全部 ≥20%、常见 30%~60%。
  3. 打分口径一调就在别处砸坑。 实测给 Quality 加一档几何可行性阈值 (偏差 >0.35 不参与少交叉的竞争):morphine 如愿退回好看的那版,但 金刚烷(语料里最常见的桥环骨架,出现 18 次)从 0 交叉变成 1 交叉, 棱晶烷从 1 处交叉变成 15 处。不是阈值没调好 —— 拿一个标量去排序一堆 随机解,本来就没有稳定的偏好结构。

读了 CoordGen 的源码(RDKit 用的就是它,BSD 开源)

CoordgenFragmentBuilder::generateCoordinatesCentralRings 的真实流程:

查模板 → newScorePlanarity → 逐环摆(挑"中心环" → buildRing) → 分子级最小化

几处我原本理解错、读了源码才纠正的:

  • newScorePlanarity 不是图论平面性,是化学几何判据:某根键属于 >2 个环; 或度 > 3 的原子其各环内角之和 ≥ 2π。"度 > 3"这个前提是承重的 —— 金刚烷的桥头原子度 3、在 3 个六元环里、内角和恰好 360°,漏掉这个前提就会把 金刚烷、双环[2.2.2]、morphine 全判成"摆不了"。
  • CoordGen 确实做弹簧松弛,只是在分子级(CoordgenMinimizer),而且力场里 专门为桥环写了一条:与本环共用 >2 个原子的邻环会把弯角静止角按"更大的虚拟环" 算 —— 双环[2.2.2]的六元环因此按 8 元环的角度松弛。所以它是"几何起手 + 离散 翻转 + 带键角项的受约束最小化",不是纯几何。
  • buildRing 在共用 ≥3 个原子时,用的是 orderChainOfAtoms 把共用原子排成 一条链、取链的两端,隐含要求共用原子构成一条简单路径;不是按秩取首尾。
  • 它先 simplifyRingSystem 把只与一个环融合的侧环剥掉,判据与摆放只对核心 环做;而且 buildRing 整环赋值,后摆的环会把先摆的原子搬走 —— 它的坐标 本来就不自洽,靠后面的最小化收拾。

落地的是另一个构造:弧法

不照抄"整环刚性正多边形 + 首尾对齐 + 整环覆盖",改成:已摆好的原子一律不动, 新原子按连续段(弧)跨在两个已放锚点之间。

一段有 k 个新原子的弧 = k+1 根单位键跨过锚距 d,解等张角 sin((k+1)θ/2)/sin(θ/2) = d,新原子落在半径 1/(2 sin(θ/2)) 的圆弧上。 d < k+1 时唯一有解,两个镜像挑不撞的那个。

好处:共用原子天然一致(根本不动它们);邻稠是它的特例(d=1、k=n−2 解出来 正好是正多边形),稠环与桥环一套代码;冗余环自动是空操作 —— 而本库的 ring_set 不是严格 SSSR,177 个桥环体系里 65 个含"零个新原子"的环。

量出来的:好得多,但有一处硬伤

把弧法当额外候选接进离线生成器(bestQuality 取最小,多一个候选只 可能更好),重跑表:

模板表 54 条 之前 弧法当候选
键长偏差中位 0.232 0.000
偏差 <0.001(精确单位键长)的条数 0 30
自交非零的条数 / 自交总数 6 / 8 6 / 8(不变)

语料里最常见的骨架跟着受益:降冰片烷(出现 37 次)0.254 → 0.000。

装上这张表跑全量:

large.smi 旧表 弧法表
—— 有骨架原子被摆成 180° 184 32
原子不重合(违例) 79 75
—— 两端都不是端基的 38 34
—— 有标签在键上塞不下 1148 1145
—— 其中有键交叉 62 74(变差)
—— 布局已退化的 44 56(变差)
写法无关(违例) 9 17(变差)

写法无关涨了 8 处,所以这张表没发。 本库有明确先例:"第 6 个初值:交叉多消 6 处、写法无关多 6 处违例 → 不要,写法无关是头号契约,不拿它换"。

新增的 8 处全部落在退化布局上,其中 5 处是新出现的"形状相同、摆位不同" (orient 那一类)。根子多半不在弧法:弧法给的坐标更精确、更对称,把下游 本来被浮点噪声掩盖的平局暴露了出来。这正是下一步要查的。

这一轮落地的

arcs.rs 与它的判据进仓库,但不接线:模板表原样不动,全量审计与 HEAD 逐行 相同,行为零变化。它现在是一块验过的积木:

  • 五条变异逐条打红:CLASH 归零(不再拒重叠)/ 镜像永远取第一个 / 起手环绕向不定死 / 环序平局改用存储下标 / 圆弧半径写错 5%。
  • 摆不出来就说摆不出来:两个锚点之间三条等长桥时(双环[2.2.2]辛烷、金刚烷、 morphine 骨架),等张角弧对给定锚距只有两个镜像解,三条挤两个位置必然重合 —— 这一条必须弧法自己拒,Quality 数的是键交叉,两个原子精确重合不产生交叉, 而它的键长偏差反而是完美的 0,靠调用方挡不住。实测 54 条骨架里能摆 30 条。

下一步:查那 8 处新增的写法无关违例(几乎肯定是下游被精确几何暴露出来的 平局),修掉之后再接线。

桥环:morphine 画成一团乱麻,补的是语料不是算法

morphine 先前 未解冲突 7、交叉 4,HON 两个标签叠在一起,键交叉四处 ——一张读不出结构的图。RDKit 也不理想。

业界的答案是模板,不是更好的算法

Steinbeck 讲 CDK 的结构图生成时原话:"对某些桥环体系,没有已知算法能给出好 结果,因此需要模板机制;CDK 里由 TemplateHandler 做这件事。" CDK 的 RingPlacer 把螺环/稠环/桥环分派给不同处理器,桥环那支就是查表。ChemDraw 走 同一条路 —— 它给的是模板面板(甾体、糖、生物碱),而不是让 cleanup 去算。

这套机制我们本来就有(templates.rs + 那张表),缺的是覆盖面:morphine 的 环系 18 个原子,指纹 C1C2CCCC34C5C(CC4CCCC23)CCCC15,表里没有 —— 因为表是从 large.smi 生成的,而那是通用语料,生物碱、萜类这些"经典难画"的骨架它基本没有。

为什么不在运行时把搜索加大

第一版方案是"查表落空、且 5 起点的结果自交时,当场把搜索加大"。审核否掉了, 理由值得记:这套昂贵搜索现在是离线的,产物是签进版本库的常数表,写法无关由 "表是常数"结构性保证。 搬到运行时等于第一次把 settle 的浮点噪声链路接进 在线路径 —— bonded 按键的存储序收、力按那个次序累加、len < BOND_LEN * 1.2 是个没有容差的硬分支。候选一多,(自交数, 键长偏差) 打平会变成常态,胜负落到 量化坐标序列上,那点末位噪声就直接决定挑哪张图。

而且在现有语料上根本验不了:表覆盖了 large.smi 97.7% 的骨架,升级分支 几乎一次都不会跑,"全量不变差"这个闸会轻松通过而什么都没验证。

所以改走离线补表:新建 harness/corpus/bridged.smi

头一版语料里 9 条根本不是那个分子

这是这次最该记的一笔。我按记忆写了 32 条天然产物结构式,用 RDKit 验过"能 解析"就收了。审核逐条与 PubChem 比对,查出:

名字 实际写成了什么
马钱子碱 C21H24N2O4,真身是 C23H26N2O4 —— 少两个碳
雪松烯 C17H28,真身 C15H24 —— 多两个碳,而且根本不是桥环(邻稠)
立方烷 9 个碳
棱晶烷 7 个碳
扭烷 含两个五元环,真身全六元环
桉叶素 氧与偕二甲基位置对调
双环[2.1.0]戊烷 写成了双环[1.1.1]戊烷
长叶烯 写成两个孤立甲基,真身是偕二甲基
狄氏剂核 9C/6Cl 带一个环丁烷,真身是 13 个环原子的双降冰片体系

另有 7 条立体写错(与任何有名的差向异构体都不匹配)。

能解析不等于是那个分子。 这些错分子既往表里塞了垃圾解,又挡掉了 7 个 本该收进来的真骨架 —— 立方烷、棱晶烷、扭烷、房烷、雪松烷、长叶烯、狄氏剂, 写对了每一个都是表里没有的新骨架。

现在的规矩:

  • 一律不写立体。 skeleton_of 本来就把立体剥光,写了进不了表,写错了却会 让人把这份文件当立体回归样本用。名字指的是那个骨架
  • 结构式要么是能自己数清楚的简单笼,要么核对过分子式与连接方式;拿不准的不收。
  • 非桥环的一概不收 —— 青霉素 G 与生物素都是邻稠,实测 generate 一处退化都不 报,对表零贡献,整节删掉。

改完 25 条,分子式逐条核对过。

生成器改动两处

  • 两份语料:large.smi 按频次取前 TOP,bridged.smi 里的骨架无条件 全收(它们在通用语料里出现零到一次,按频次排永远挤不进前 50)。
  • 频次只由 large.smi。头一版让额外语料也计数,结果它贡献的那一两次把 排名搅动了 —— 原本第 47~50 名的 4 个骨架被挤出前 TOP,凭空丢了模板

表从 44 条涨到 54 条,原有的一条没丢

结果

morphine 补表前 补表后 加大搜索预算后
未解冲突 7 1 1
键交叉 4 1 0

图从一团乱麻变成了能认出来的吗啡烷:芳环在右下、呋喃氧在中间、环己烯带羟基在 左上、N-甲基哌啶在左下。

"剩下那一处交叉是几何下限" —— 这句话是错的

先前这里写着"拿 RDKit CoordGen 对同一个骨架跑 20 次重启,最好也是 1 处,这是 下限"。别人的算法也做不到,不等于做不到。

真正该问的是这个骨架图是不是平面图:

morphine 骨架  18 原子 22 键   networkx.check_planarity → True

是平面图,按 Fáry 定理必有无交叉的直线画法。加大搜索预算实测,确实找得到:

首次出现在第几次 自交 最大键长偏差
—(2 万预算内最好的) 1 0.254
22.4 万 0 0.620
32.3 万 0 0.443
53.3 万 0 0.398
80.1 万 0 0.358
300 万 0 0.358(80 万之后 220 万次再无进步)

所以生成器加了自适应预算:基础 2 万跑完仍自交的骨架才继续搜到 40 万, 一到 0 交叉就停。种子流是同一条,已经不自交的骨架逐字节不动 —— 实测 54 条里 44 条坐标完全相同,变的正是原先自交的那 10 条。

但 morphine 这一个分子上,指标与观感是背离的

必须如实说:0 交叉的 morphine 画得比 1 交叉的难看。

交叉 键长偏差 看着怎么样
1 交叉版(补表后) 1 0.254 能认出的吗啡烷,N-甲基清楚
0 交叉版(发出去的这一版) 0 0.443 N 附近一堆近乎共线的键挤在一起,N-甲基几乎看不出来
0 交叉版(0.358) 0 0.358 更糟:芳环里冒出个 CH 标签,未解冲突 1 → 2

而且这不是搜索没搜到:300 万次里最好的 0 交叉解就是 0.358,0 交叉的吗啡烷 布局键长本来就不齐

还是留了 0 交叉版,理由是全量语料判决,不是这一个分子的观感:

large.smi(17662 分子×规范) 无升级 升级后
—— 其中有键交叉 74 62
—— 布局已退化的 56 44
—— 两端都不是端基的 52 38
—— 有骨架原子被摆成 180° 188 184
—— 有标签在键上塞不下 1149 1148
—— 涉及端基键的 22 24(变差)
硬性质十条 / 写法无关 / 外部判官 全同 全同

Quality 的次序"交叉优先"是本库明写的取舍 —— 交叉可能让人读错,键长不齐 只是难看。为一个分子的观感去推翻它,才是不该做的事。这笔账记在这里, 谁要重开这个话题,先看这张表。

差点发出去一张比说的更糟的坐标

头一版的升级写成了 "一到 0 交叉就 break"。而 Quality 是三档的 —— 自交归零之后,第二档"键长偏差"就不再优化了。拿到的是第一个 0 交叉解, 不是最好的那个:

morphine 骨架 自交 偏差
一到 0 就停 0 0.620
跑满预算 0 0.443

上面那张观感表评的还是 0.443 那一版 —— 而当时发出去的是 0.620 那张。 审核把表里的坐标取回来重算才发现:文档描述的不是实际发出去的东西。

改成"升不升级在 k == TRIES 处一次性决定,决定了就跑满"。34 原子那条也跟着 从 0.384 降到 0.293,全量语料的交叉/退化/冲突一处不动,纯赚。

顺带把生成器的输出补上偏差(// 出现 N 次,自交 M,偏差 X.XXX)—— 正因为 先前只打自交数,这个错才不会出现在 diff 里。

生成器原来在读它自己正在生成的那张表

改完"跑满"之后,重跑生成器 54 条坐标一个字都没变。查下去:one_skeleton 里调的 relax 先查表 —— 于是 best 的初值来自它正在生成的那张表。 表里已经是 0 交叉的骨架,一进来就判"不用升级",k == TRIES 直接 break, 永远搜不到更好的解。生成器成了不动点迭代,不是语料的纯函数。

改成把 relax 的多起点部分照抄一遍、只是不查表。之后:

  • morphine 拿到 0.443 ✓
  • 装上新表再跑一遍,输出逐字节相同 —— 生成器与表内容无关这条现在是真的

并行化,把跑满的时间代价吃回来

54 条骨架彼此完全独立,std::thread::scope 一 spawn、结果按下标收回再统一 打印,确定性一点不掉(每条自己一条 splitmix 种子流)。

串行 并行
一到 0 就停 9 分 19 秒 ——
跑满(采用) 约 15 分 4 分 51 秒

表里的自交数原来没有任何判据守着

生成器打出来的 // 出现 N 次,自交 M 只是注释 —— 人改一行坐标它不会变红。 而"自交非零从 10 条降到 6 条"这个成果,先前全挂在那串注释上。

补了一条不带 #[ignore] 的判据:遍历 TABLE,按骨架自己的规范秩把坐标取回来 重算自交数,断言总数 ≤ 8(现值 8 = 2+2+1+1+1+1)。变异验证:把任一条模板的 坐标前后对调 → 变红。

bridged.smi 整份上新旧表 A/B(25 个分子 × 2 规范):

老表(44 条) 新表(54 条) 加大预算后
有键交叉 20 10 6
查表没命中 20 0 0

large.smi 的数字一处没动 —— 干净率、交叉、退化、写法无关 9、硬性质 十条全部逐行相同,外部判官仍是 496 一致、0 读反。这不能反过来说明改动没用: 新增那 10 条全部标着 出现 0 次,通用语料里根本没有这些骨架,"一行不动"是算 出来的必然。收益在语料之外的真实药物上。

立方烷与棱晶烷的模板自交 2 和 1。这不是几何下限 —— 表里 54 条骨架 networkx.check_planarity 全为真,包括这两个笼和剩下那 4 条到不了 0 的。 是随机重启这个方法够不着:实测各跑 200 万次(同一条种子流),6 条里只有 C1C2C3CCCCC3CCC32CCC1C1CCCCC13 在第 64.9 万次到了 0,其余五条 200 万次 仍然自交 —— 连 10 原子 12 键的 C1C2CCC3CC2CCC13 都够不着。加预算不值, 要换方法(见下)。

表落空现在报得出来,而且分两种

Degradation::BridgedRingSystem 加了 template: templates::Status,三态:

意思 该做什么
Hit 坐标是表给的 ——
NotInTable 指纹算出来了,表里没有 补进 bridged.smi 重跑生成器
NoFingerprint 指纹根本算不出来 查那个分子本身

头一版只有 from_template: bool,把后两种混成一档,文案是"该补进语料"。

而查下去,全量语料里那 8 处全是 NoFingerprint,而且全部出自两个分子 —— 二茂铁。 铁与环戊二烯基的五个碳全成键,骨架全碳化之后那个原子度数 9, sanitize 过不去。补语料对它没有任何用,要改的是骨架抽取本身怎么对待这类 η5 配位。100% 的情形都被指错了方向。

全量 17662 个分子×规范:NotInTable 0 处,NoFingerprint 8 处 (large.smi 第 2135、5558 行的二茂铁,各 4 处)。判据里用的就是这两个分子。

lookup 现在把状态一并带出来,relax 直接返回 —— 先前调用方为了知道"命中没有" 又调了一次 lookup,而那里面是建分子 + sanitize + 规范化,每个桥环系统、每种 规范、审计里每种写法都白付一遍。

顺带查出来:审计的"跳过"把两件事混在了一起

audit.rs 读语料时没跳 # 注释,靠"解析失败"混过去。改了之后 large.smi跳过 32 变成 跳过 8 —— 原来 24 条是注释行,真正解析不了 的只有 8 个,先前全藏在那个数里。

已知缺口

bridged.smi 上有 1 个分子(scopolamine 骨架,×2 规范)违反写法无关,分类是 [形状相同,摆位不同|布局已退化] —— 与语料里那个双腙同族,根子在 orient 的 规范朝向。large.smi 那个闸不受影响(仍是 9)。记在这里,没修。

两个过程上的教训

一、str.replace 静默失败。 给这条加判据时,我往测试模块里插函数,锚点 函数名写错了 —— 什么都没做。三条变异跑下来全绿,我差点当成"判据空过"去改判据 本身,后来 grep 才发现函数根本没插进去。现在插入前先 assert anchor in s变异全绿时,第一件事是确认要验的东西真的在。

二、判据说的话要与它验的事对得上。 头一版判据叫"坐标是不是查表来的",实际 只验了"这个骨架在不在表里"。审核做了个我没想到的变异:relax 里那个查表 短路关掉(坐标改由 5 起点松弛给出、标志位仍报 Hit)—— 四条相关判据全绿。 现在 Hit 时会逐点断言坐标等于表里那一组。

标签朝向:氢只能挂左右两边,不够用

对乙酰氨基酚的酰胺氮,两根键一根指左上、一根指右上,横向分量抵消掉了。 render::h_side 只看 x 分量,于是氢被挤到右上那根键的下面 —— 而正下方 整片是空的。RDKit 把它摆在氮的正下方。

RDKit 的规则,以及抄错一个数会有多贵

DrawMol::getAtomOrientation:键向量和 nbr_sum,标签往它的反方向伸; |slope| ≤ tan70° 走左右(E/W),否则走上下(N/S);度 1 的原子一律左右 (源码注释原话:"atoms of single degree should always be either W or E")。实测 这一条挡下 2799 个本会判成竖排的带氢标签。

RDKit 还有三条本库故意没照抄的,免得下一个人以为已经全了:度 3 的竖直键 特判(实测改 144 个原子朝向、其中 14 个有标签、0 个标签含氢,所以先不补, 而且它"取第一根竖直的键"是拿存储下标打破平局,直接违反写法无关); |nbr_sum.x| ≤ 1e-4 时强制判竖(共线原子判竖会把标签盖到键上,涉及 58 个带氢 标签);度 1 的左右选择(RDKit 一律给 East,本库按 sx 符号定,差 805 个标签)。 度 0 那条照抄了 —— 不抄的话水画成 OH2、氯化氢画成 ClH

第一版方案把阈值写成了 30°,那是个结构性的坑:本库的图被 orient 吸附到 30° 栅格,而查阈值的是度 ≥ 2 的原子 —— 它的键向量和是两个以上栅格向量之和, 方向落在 15° 栅格上,|tan| 的取值是 {0, 0.268, 0.577, 1, 1.732, 3.732, ∞}, 而 0.577 正是 tan30° —— 大批键向量和会精确落在阈值上,横竖之分退化成由 浮点末位决定。审核人按这个错阈值量出 22.34% 的标签落在断点上。

改回真正的 70°(tan = 2.747,不在上面那个集合里,离最近的 1.732 和 3.732 都有 余量)之后,全量语料实测(cargo run -p omgkit-depict --release --example label_dirs,测量程序入库,数字复核得了):

个数 判别量 \|sy\| − tan70·\|sx\|
精确落在断点上 70(其中度 ≥ 3 的 58 个) ≤ 4.663e-15
真正有横竖之分 19624 ≥ 4.461e-3
—— 其中判成竖排(度 ≥ 2) 3202(16.3%)

两簇之间空着 12 个数量级,容差取 1e-9 落在正中间。度 ≥ 3 的那 58 个是真 破口:summol.neighbors 的存储序累加,三项以上的浮点加法不满足结合律。

判别量写成差 |sy| − tan70·|sx|,不是商 sy/sx —— 后者在竖直时溢出。

阈值写死成字面量,不在运行时算 tan tan 不是正确舍入的,跨平台、跨 libm 版本能差 ulp,而头号契约要求逐字节相同的图元。判据 a_seventy_degree_threshold_is_what_is_written_down 钉住它 —— 先前那三条判据 在 tan60、tan80、乃至末三位换位这三种变异下全是绿的,而换 tan60 会让 3858 个原子改向、换 tan80 会让 7587 个改向。

竖排接进绘制:氢真的摆到了符号下面

两条 Primitive::Text(符号一条、氢一条),Primitivesvg.rsraster.rs 三处一个字都不用改。SVG 里换行还得显式给第二行 x(只给 dy 会 沿上一行的笔位继续往右排,这个坑 svg.rs 里踩过一次),发两个图元连这个都绕开。

只有"纯符号 + 氢"才竖排。 带电荷、同位素、自由基一律横排 —— 竖排要求符号 那一行只有符号,否则整行居中会把符号推偏,那正是 Label::dx 当初要解决的问题、 只是搬进了行内。实测这个限制的代价很小:

竖排候选 3202
纯符号 + 氢(真会竖排) 3118(97.4%)
带电荷 84
同位素 / 自由基 0 / 0

为那 2.6% 引入"每行各自的 dx"不划算。

结果:

竖排之前 竖排之后
—— 有标签在键上塞不下 1271 1149(−122)
硬性质十条 全部 一处不变
外部判官:构型一致 / 读反 496 / 0 496 / 0

审核查出来的:盒与落笔从来没被绑在一起

half_w/half_h/dybuild 算,而 lines() 另算每一行摆哪 —— 这是同一个 Label两条互不相干的算路,而全仓先前没有一条判据把它们绑住。审核人拿 两个变异证明了这一点,两个都 121 条判据全绿溜过去:

变异 后果 先前
竖排的 half_w 只按符号算 氢那行的下标被键画穿(最宽的 CH₂ 每边削掉 0.167 em) 全绿
氢那行横向挪半个盒宽 氢跑出盒外 全绿

全量审计也看不见:bounds 用的是同一个盒(自洽),不出画布 报不出来; 线端不压字 盒窄了只会少报

补的判据从 runs 独立重算每一行的字形范围,再断言它落在盒里。四个变异 (上面两个,加"少算下标下沉""竖排 half_h 算错")逐条打红。

还有三处同样的错,在判据自己身上

审计里那两处盒心修好之后,单元判据里的同一份没修:

  • no_drawn_line_runs_across_an_atom_label 仍按"盒心在原子上"算 —— 按同口径 全量扫过:错盒命中 233、真盒命中 229,差的正好是那 4 个。这条判据的分子 表里已经有对乙酰氨基酚,只是那个氮的约束恰好落在 x 上、余量 1.99pt 才没红。
  • 不出画布 的单元版把盒往挪了 3.95pt,方向是反的 —— 不会假阳,但下边缘 变成了假阴:标签真戳出画布底边它看不见。
  • can_stack 的自由基那一条一条判据都没有(删掉它 121 条全绿)。语料 8831 个 分子里没有"既带氢、几何又够竖排"的自由基原子,端到端永远够不着 —— 所以改成直接判 can_stack 这个纯函数,它只看分子不看几何。

两个几何上的错

  • gap_em 的下标补偿只有氢在上面时才需要。 氢在下面时下标朝外,让了纯属 白撑高 —— 实测 NH₂ 两行字盒之间的白会从 0.050 涨到 0.147 个键长,比 NH 宽出三倍,看着就是两行没对齐。
  • 摆不成竖排时 build 原本是 debug_assert release 下竖排那支只 push 符号与氢,电荷、同位素、自由基一个都不发 —— [NH4+] 会画成 NH4,那个 + 静静没了;0 个氢时第二行是空切片,还会发一条空的 Primitive::Text。而 LabelPlacelabel_for 都是公开的,调用方给什么由不得我们。改成回落横排。

refine::radii 的注释数字也修了:竖排外接圆是 0.836 em 不是 0.849。实测 横排 0.806 —— 竖排几乎不改外接圆(+3.7%),它做的是把各向异性转了 90°

盒子窄了一半,横着的键因此宽松 —— 先前方案里"竖着的键会更挤"那句因果是反的: 竖排多出来的高度长在背离键的那一侧,沿键方向的净空基本不变。

一个符号错,盒上下对称时看不出来

label_clearancedir 该用哪个坐标系,当时想当然以为 trim 在画布上跑, 加了一句 up = (d.x, -d.y)trim 其实一直在布局坐标里跑 —— scene 是 先 trimto_pt

竖排之前 dy 恒为 0、盒上下对称,翻不翻结果一模一样,所以这个错藏了很久 都没露头。竖排一上线,全量审计的 线端不压字 当场从 0 破到 4。

排查顺序值得记:先怀疑判据(audit.rs 那两处确实还在用 Point2::new(l.dx * scale, 0.0),没跟上 dy),改完数字一点没动 —— 这才排除了假阳、坐实是实现的问题。最后靠打印 trim 收到的 len 认出来: 它打的是 0.9300,那是布局长度,画布该是 27.9pt。

现在有一条单元判据(a_stacked_label_leaves_less_room_towards_the_bonds_than_away_from_them) 守着,把 offset() * -1.0 换成 Point2::new(-l.dx, l.dy) 就变红 —— 不用再等 全量审计。

度 0 的原子:水本来画成了 OH2

没有键就没有"背离键"这回事,键向量和是零向量,一路落到 East。实测:

输入 改之前 改之后
O OH2 H2O
Cl ClH HCl
S SH2 H2S

全量审计查不出这条 —— large.smi 里度 0 原子一个都没有。而水合物、盐的 对离子在真实输入里很常见。判据单独立了一条。

这一步刻意不改行为

label_dir 与判据先落地,h_side 仍按老规矩定左右。试过把上下两向直接折成 East(即"竖直的一律写 OH"),:

塞不下
折成 East 1356
保留老的左右选择(采用) 1271

折过去等于丢掉这批标签本来定得好好的左右选择。要拿回来,得让标签真的会 竖排(盒子宽度减半),那是下一步的事。

全量审计逐行与改动前相同。

最后那 2 个"摆位不同":线索指向 depict 之外

C/C(=N/N=C(\C)/c1nnn(n1)C)/c2nnn(n2)C(双腙)在两种写法下摆位不同。逐段查:

问题 结果
分岔在哪一阶段 4-cistrans —— fix_cis_trans 之前的坐标完全相同
orient 能不能合并 不能:它之前 2 种姿态,之后仍是 2 种。两者不是"30° 栅格旋转 + x 轴镜像"的关系,而 fix_cis_trans 是绕键轴镜像,轴可以是任意角度
fix_cis_trans 为什么两次做得不一样 键上存的顺反标签就不一样
写法 A: 键(r4,r9) Trans 参照 r[3,8]    键(r5,r8) Cis   参照 r[9,2]
写法 B: 键(r4,r9) Cis   参照 r[3,8]    键(r5,r8) Trans 参照 r[9,2]

同一根键、同一对参照原子(按规范秩),标签却是反的。 stereo_atoms按 位置存的(第一个属于 begin 那一侧),而 begin/end 谁在前是书写痕迹 —— 两种写法各自自洽,但 fix_cis_trans 拿标签做判断,于是一次镜像、一次不镜像。

这条线索指向 omgkit-ioperceive_bond_stereoBondData::stereo_atoms 的位置约定,不在 depict 里。 没有继续改 —— 跨 crate 的立体编码约定要单独立项, 不该在画图这条线上顺手动。记在这里。

画不出三根键就表达不了构型:最后 2 个 unwedged 也补上了

楔形只能说"这一根出/入平面",其余的算在平面里。要定死一个四面体,平面里至少 得有两根键把方向锚住 —— 总共画出三根

C[P@H]C[P@@H]CS(=O)(=O)[O-] 的磷是两个邻居 + 一个氢 + 一对孤对,画出来 只有两根键,谁也读不出构型 —— 先前只能进 unwedged。补氢的判据因此加一条: 画出来的键少于三根就补(前提是有氢可补)。楔形可读 / 楔形落地 620 → 622,unwedged 的错读清零。

判官把"读不出"和"读反了"混成了一档

补完之后判官报「不一致且没报 2」,吓了一跳。查 RDKit 源码:它的 2D→手性 只认 S(16) 与 Se(34) 这两种三配位中心 (Chirality.cpp:anum != 16 && anum != 34 && tnzDegree != 4 就跳过), 三配位的磷不在它的支持范围

这不是画错了,是读者读不出。 两件事的危害差着量级 —— 一个是覆盖面,一个是 给了假信息。判官因此拆成四档,新增「外部实现读不出」,不当违例也不藏起来。

拆分之后:496 一致 / 0 读成相反的构型 / 2 读不出。拆分前那 2 处正是这两个磷, 没有把任何真实的对映体错误重新归类 —— 这一条核过。

那两个磷的正确性证据是间接的,要说清楚

我们唯一的外部判官读不出三配位磷,所以没法直接验。间接的依据是:补氢之后 它走的是"孤对电子当第四配体"那条分支 —— 与亚砜完全同一段代码,而那条分支 做过奇置换变异(孤对挪到槽位 0,14 个中心全部翻成对映体,判官当场红)。

这是证据的转移,不是直接验证,照实记在这里。

剩下 18 根环上楔形:15 根是理论下限,去读了 RDKit 才敢这么说

补显式氢把环上的楔形从 159 压到 18。剩下的不是缺口,逐根查过:

根数 是什么
没有别的合法选择 15 中心四根键全在环上,又没有氢可补 —— 补不了,也没有合法楔形
有别的选择却没用上 3 "两个相邻立体中心抢同一根共用的非环键"那笔取舍

第一档是不是真的没救,不能靠推理,要去看别人怎么办。 把这些分子交给 RDKit, 读它写出来的 molblock 立体列:

C1CSC[C@@]12C(=O)NC(=O)N2         RDKit 补 0 个 H;唯一的楔形 (4,3) 在**环键**上
C1C(C[C@@]23[C@@]1(O2)C=CC=C3)O   RDKit 补 0 个 H;两根楔形**都在环键**上
Cc1ccccc1[C@@H]2[C@@H]([C@@]23…)  三根楔形里有一根在环键上

RDKit 也画在环键上,也不补氢。 季碳没有氢可补,而四根键全是环键 —— 这一档是理论下限,不是我们做得不够。

第二档 3 根:相邻的两个中心只有一根共用的非环键,谁拿走谁受益。让出去就有一个 中心画不出构型 —— 信息比样式重要,所以宁可多一根环上楔形,见 stereo::assign_wedges 里的两跑取优。

审计里新加一档 —— 有楔形只能画在环键上(26 个分子×规范)与 —— 其中本可避开(抢共用键输了)(4)。不进违例:它们不是画错了。

剩下的 9 处写法依赖:逐个查过,分三类

从 223 降到 9(0.05%)之后逐条查了,是 5 个分子:

分子数 是什么
坐标相同,楔形方向相反 1 C(#C)[C@@]1(CC[C@H](c1ccccc1)CC1)O —— 两条环支路等价,根本不是 CIP 立体中心(RDKit 也给不出 R/S),分子非手性,两张图是互为镜像的同一个化合物,都对genuine_tetrahedral 拿不准时保留,于是照画不误,而方向没有依据
形状相同,摆位不同 2 orient::canonicalise 试 12 旋转 × 2 镜像取最小键。输入坐标末位一抖就可能换一个姿态 —— 高度对称的分子上几个朝向本来就同样规范
形状就变了 / 退化 2 笼状胺与镍配合物,布局本身已退化(degraded 非空),坐标不成形状,谈不上"同一个摆法"

没有一类与手性的画法有关。 第一类是"该不该画"的问题(上游的真中心判定), 后两类是布局。都记在账上,不假装解决了。

判据自己在欠采样:头号契约的违例被少报了 10%

写法无关是本 crate 的头号契约,而它的判据是抽样的 —— 每个分子随机换几种 写法比一比。先前定的是 5 种,这个数从来没人量过。

把它拉开量:

每分子比几种写法 5 10 30 60 100
写法无关违例 201 221 223 223 223

5 种漏报了 22 处(10%),30 种就饱和,60 与 100 一个不多。默认因此从 5 提到 30,代价是全量一遍从 ~45s 变成 ~55s。写法数同时做成了命令行第二个参数, 以后可以随时再量。

这不是画得更差了,是先前看不见。 同一条判据的违例数一路 134 → 201 → 223, 两次涨都是判据自己变灵敏:第一次是把退化成恒等的搅拌器换成真置换,这一次是 提采样数。判据的灵敏度本身就是要量的东西,不然"零违例"可能只是没查到。

孤对电子也是一个配体:18 个画不出构型的中心里 14 个是这一档

unwedged 里那 18 个"没画出构型"的中心,分类之后是:

个数
孤对电子当第四配体,三根键都画得出来 14 ← 能救
孤对 + 隐式氢,只画得出两根键 2 ← 要补 H
布局退化,几何读不出来 2

亚砜的硫是三配位 + 一对孤对,构型照样确定,画法与碳上的隐式氢一模一样 —— 楔形打在三根键之一,孤对在它的反面。先前 read_chirality 只认 (4, 0)(3, 1),(3, 0) 一律返回 None

槽位由外部判官定,而且第一个变异是空的

孤对占哪个槽位,搞错就是静默画对映体。两个变异:

变异 结果
孤对挪到槽位 3 全绿 —— 3-轮换是偶置换,读出来一模一样
孤对挪到槽位 0 判官红,14 个中心全部报「画成 S,应当是 R」

第一个变异是空的,而它看上去和第二个一样"像个变异"。理论上早该知道 (与补 H 那条恒等式同源),但先跑了才确认。

氮不算

三配位氮的孤对翻转极快(氨的翻转垒只有 24 kJ/mol),常温下两个构型互变 —— 画楔形是在断言一个不存在的构型。被环卡住的(氮杂环丙烷、Tröger 碱)确实稳定, 但那看环张力不看元素。宁可漏报也不乱画。

判据 a_three_coordinate_nitrogen_is_not_given_a_wedge 头一版写的 C[N@](C)CC 有两个甲基,genuine_tetrahedral 先把它当假中心滤掉了 —— 把氮加进孤对表这个变异照样绿,判据是空过的。换成三个取代基各不相同的 C[N@](CC)CCC 才有牙。

结果

外部判官,按中心
构型一致 480 494
不一致,但已报 unwedged 18 4
不一致,且没报 0 0

审计里 楔形可读 / 楔形落地 从 602 涨到 620,违例仍是 0;其余指标一项没动。

楔形该躲开哪根键:两条忌讳互相冲突,所以两种序都跑

楔形该躲开环键(IUPAC 的图示建议:立体键该画向取代基),也该躲开与另一个 立体中心共用的键(读者会问这个楔形在说谁)。两条常常冲不到一起,但相邻的 两个中心只有一根共用的非环键时就冲了 —— 谁先拿走谁受益,另一个被饿死。

原先的排序是"共用键最差"在前,于是环键反而排在前面被选走。全量语料 159 根楔形 落在环键上,其中 4 根本来能避开,而这 4 根全部是"环外键的对端也是立体 中心"。

把"环键最差"提到最前面,方案审核实测是修 2 退 1。我照着重测了三档:

环上楔形 unwedged
只用「共用键最差」(旧行为) 159 18
只用「环键最差」 156 19 ← 少 3 根,却丢了一个中心
两种都跑,按结果挑 158 18

哪一条该优先没有普遍答案,所以不猜:两种序各指派一遍,先比画不出来的中心数 (少的赢,信息不能丢),再比落在环键上的楔形数。两个都跑过才挑,结果不会 比任何一种单独跑更差

判据 when_the_two_taboos_conflict_the_better_of_both_orders_wins 挑了两个各站 一边的分子:缩醛那个「环键最差」赢,噻嗪那个「共用键最差」赢。两个变异(只跑 其中一种)分别红在不同的断言行上 —— 一条是 unwedged,一条是环上楔形数, 各自吃劲。

剩下的 158 根:147 根靠补显式 H 才能救(三根键全在环上、有 H),8 根无解 (四根键全在环上、无 H,补不了也没有合法楔形),3 根是上面那笔没做成的交易。

反式双键:两个顶点都合不拢,而"偏哪侧"没有道理可讲

丙烯那一档修完之后,画廊里的 trans-butene 一点没变 —— 因为它是另一档: 两端都有取代基,两个甲基一边一个,按邻居计数正好抵消,于是走"票数为 0 就 对称跨轴"。两端的单键都收在原子中心,两条线谁也不从那两点出发 —— 两个顶点都合不拢,与丙烯是同一个毛病,只是这里两头都犯。

这一档我上一轮心里知道、判断它'没有写法无关的偏侧依据'就跳过了,却没写进 报告。 漏报比漏改更糟:漏改还看得见,漏报连"知道有这回事"都传不出去。

数清楚(单套规范,按审计那条会感知顺反的管线):

键数 分子数
落进"票数抵消"这一档 629 562
—— 其中两端都带标签,仍旧对称 93 88
真正改了画法 536 475

比丙烯那档(404/374)大。口径要说清楚:单元判据里的 prep 不调 perceive_bond_stereo,同一份语料数出来是 643/574 —— 两条管线对 E/Z 的可见性 不同,报数字时不能混着说。

先分出真正该对称的一支:两端都带标签(偶氮 CN=NC)。那时键都停在字盒外, 压根没有顶点要合 —— 与"端基双键内侧带标签"是同一条道理,RDKit 也单列 (calcDoubleBondLinesatomLabels_[at1] && atomLabels_[at2])。

顺带修一句措辞:"两个顶点都合不拢"只对两端都不带标签的那 302 根成立; 另外 246 根只有一端带标签,只有一个顶点要合 —— 偏一侧仍然是对的,但话不能那么说。

剩下的必须偏,而偏哪侧确实没有道理可讲:两侧各有一个取代基,谁也不比谁 更该。RDKit 取 otherNeighbor(begAt, endAt, 0) —— 那是存储序。照抄的话 同一个分子换种写法就画到另一侧去,而写法无关是本库的头号契约。

改成认画布的绝对方向:朝上那一侧,竖直键打平时朝左。坐标此时已经过 orient::canonicalise,与写法无关;而且这条规则只认方向、不认 normal 的 正负,所以键的 begin/end 换个个儿它也不变

变异验证两条,分别打在两个面上:

变异 结果
票数抵消时改回对称 a_trans_double_bond_closes_both_joints
照抄 RDKit 的存储序挑侧 the_same_molecule_written_differently_draws_the_same_lines
删掉"两端都带标签就对称"那一支 a_double_bond_with_no_inner_side_is_drawn_symmetric

第二条是这次最要紧的 —— 它把"为什么不能照抄 RDKit"从注释里的一句断言变成了 一条会红的判据

这三条里有两条是复审逼出来的,原样记下:

一、写法无关那一组我第一版填的是 ["C/C=C/C", "C(=C\C)\C", "C(/C)=C/C"] —— 后两个根本不是同一个分子,规范式是 C/C=C\C(顺式)。它能绿只因为 单元判据的 prep 不调 perceive_bond_stereo,布局看不见 E/Z,顺反画成了同一 张图。那等于把"顺式和反式必须画得一模一样"写成了硬性要求 —— 哪天 prep 对齐,红的是判据自己;更糟的是有人照着它去"修"实现,把顺反区分抹掉。

而且光把 SMILES 换对还不够:换成三种真反式的 2-丁烯,变异二就抓不住了 —— 它两端等价,存储序挑哪一端都落到同一侧。得用反式-2-戊烯(两端不等价), 四种写法都归到 C/C=C/CC,而且在感不感知顺反两套口径下都落进这一档。

二、"两端都带标签就对称"这一支先前一条判据都没有:整段删掉,100 条判据 全绿,全量审计还逐字节不变 —— 因为审计里没有任何一条性质在量偏侧。 补 CN=NC 进去才有牙。

这也是本次最该记住的一句:「全量指标不变」证明不了「没伤到别的类」。 这一改动了 536 根键 × 2 规范 = 1072 条线的画法,审计输出照样逐字节不变。真正 的证据只能逐键比 offset_dir 的返回值 —— 复审这么做了:81488 次调用按分档 对齐,ring / vote / 端基各档逐位相同,只有"票数抵消且非两端都带标签" 那 1072 条变了,一根都没多带。

另一处顺带查实的:TIE_DIR = 1e-9 不在刀锋上。落进这一档的 1072 条记录里 |normal.y| 只有两簇 —— 噪声簇 ≤ 1.97e-15、真实簇 ≥ 7.47e-02,中间空 13 个数量级。语料里没有任何一根键的左右之别由浮点噪声决定。

全量 17662:每一项指标不变,写法无关仍是 201(当时每分子比 5 种写法;现在的 口径是 30 种、223 处,见「判据自己在欠采样」那节)。

判据自己抄了一份实现,数字就不作数了

改上面那条时顺手把 squeezed_bonds 归了位。它算"两端标签加起来比键还长"用的 是居中盒,而实现 render::trim 早已改成偏心盒(整串挪了 dx,好让 元素符号落在原子上)。判据比实现多要了净空:

手抄居中盒 1818 例(10.3%)
render::box_reach 1271 例(7.2%)

多报的那 547 例是判据算错了,不是图画错了。 这是同一个坑第二次:上一回是 审计里的 canvas_pts 手抄了 bounds,实现一改副本没跟上,环内双键 从 0 炸成 1403 处假阳。

光把 box_reach 公开还不够 —— 复审指出"每端净空 = 偏心盒 + margin"和"两端 之和超过九成算塞不下"这两条各自还有四份拷贝(trim 的私有闭包、 escape_boxes、审计、单元判据里内联的那份)。于是把它们提成 render::label_clearancerender::is_squeezed,阈值收成一个 SQUEEZE 常量,四处都调它。判据要量什么,就调实现里量它的那个函数。

判据的覆盖数也跟着改了:先前 线端不压字 每个分子无条件记一次"查到",可全量 里有 86 个分子×规范一个端点都没得比(没有落在非 tight 键上的带标签端)—— 那时它是空过的。改成按"真比过"计,17662 → 17576

还加了一条断言锁住图元与键的对应:判据按 drawn_orders 数着消耗图元,将来 哪根键多发或少发一个(芳香圈、配位键、跨过交叉点断成两段),它就会静默错位、 拿这根键的线去比另一根键的标签,而且照样报绿。现在按键消耗完之后剩下的必须 正好全是标签,全量 17662 处逐一验过。

画布没给补出来的符号留地方

bounds 只问 label_for。可共线的骨架碳是 scene 另外补的符号(两根键 连成一条直线时顶点处没有拐角,不补的话图上根本看不出那里有个原子)—— 画布 不知道它存在,CH 的半宽 7.22pt 直接戳出画布右边。全量 4 处

label_at 把"这个原子真正会画出来的标签"收成一个函数,scenebounds、 判据三处都调它。这已经是 bounds 第三次因为"没按真正画出来的东西算"而破 不出画布:先是写死 HSide::Right(2 处),再是不看 dx(2 处),这回是 不看补出来的符号(4 处)。每一次都是同一句话 —— 画布要按画出来的东西留白, 而不是按"应该画什么"的一个近似。

第四处没跟过来,不能不说:refine::radii 仍只问 label_for,共线的骨架碳 在那里按裸原子的半径 0.25 个键长算,而它将被画出的 CH 盒半径是 0.559 —— 布局给它留的地方不到实际的一半。这不是漏改:消冲突跑的时候坐标还在动,"共线 不共线"当时判不了,label_at 在那个阶段没有定义。记在这里,连同上面那条 radii 仍用居中外接圆的账一起。

原子重合:图上会凭空多出一个环

两个原子叠在同一点上时,它们各自的键首尾相接 —— 图上就多出一个分子里没有的 环,而读者没有任何办法看出那个环是假的。一个三萜实测画出过一个三元环,三条边 正好都是一个键长。

这一条最早占 1064/17662(6.0%),而且距离全是正好 0 —— 布局走的是 30° 栅格上的单位步长,两条支路撞到同一个格点是系统性的,不是浮点抖动。抽查的 300 例 全部都报进了 unresolved,所以"画不好要说出来"这条守住了;但"未解冲突"这个 说法太轻,它掩盖了"多出一个假环"这件事。

改法:放取代基时先看那个位置有没有人,占了就按 30° 一档往两边挪。角度偏离 理想值只是难看,不会让人读错结构。剩 89 例(0.5%),都是五档之内腾不开的。

键交叉:先前的归类是错的

我一度把 381 例交叉归成"320 例翻转够得着却没修掉"。这个归类是错的。 按 "交叉的两根键各是什么身份"重新分桶:

例数 占比
同一个稠环系统内部自交 330 86.6%
链/连接键参与 16 4.2%
只涉及端基键 25 6.6%
两个环系统撞在一起 10 2.6%

那 330 例与"degraded 非空"是同一个集合(实测 330/51 的交叉表逐项对上)。也就是 说 86.6% 的交叉不是消冲突没做好,是 rings::relax 的输出本身自交 —— 而消 冲突在几何上根本够不着:稠环系统是 2-连通的刚性块,可翻转键的定义排除环上 的键,所以同系统内两根键的相对位置在任何翻转下恒定。

翻转的天花板也量过了(枚举全部 2^k 个可达构型,k 是可翻转键数):381 例里 只有 19 例能靠翻转清零,362 例一处也动不了。

桥环松弛:多起点

relax 是局部下降,落到哪个局部极小全看初值,而它只有一个初值。改成 5 个由 规范秩派生的初值,按 (系统内自交数, 最大键长偏差, 按规范秩排的量化坐标) 挑最好:

现状 多起点
有键交叉的图 381 281
其中环系统自交 330 230
写法无关违例 129 125

最管用的初值是"最大的环先摆成正多边形,其余原子沿已放好的邻居向外铺开" —— 前四个初值都是"所有原子摆在一个圆上",拓扑上太像,弹簧下降往往收敛到同一批坏 极小。

两处踩到的写法依赖,都在这里记一笔:铺开时挑锚点用了 neighbors 的存储序 (违例 129 → 349),选优的平局键用了 BTreeMap 的下标迭代序。都改成按规范秩。

还试过第 6 个初值(多边形起手 + 新原子放进最大空隙):交叉多消 6 处,写法无关 却多 6 处违例。写法无关是头号契约,不拿它换,所以没要。

剩下的 51 例(布局没退化的那批)

二维结构式里键交叉是缺陷(唯一正当的交叉线是"顺反未定"的交叉双键记号,那是 另一回事)。这 51 例布局是干净的,却仍有交叉。

试过两条路,都基本无效,记在这里免得重做:

  • 放取代基时躲开已画的键。 只挡下 1 例(382 → 381)。原因是 BFS 到那儿时, 与它交叉的那根键多半还没画。这个检查留着了 —— 它把原子重合从 89 降到 74。
  • 把交叉在打分里的优先级提到碰撞深度之前。本来就在前面,是我读反了 代码;真按深度优先跑一遍,有交叉的图从 381 涨到 415,未解冲突一个没少。这条 测量结果已写进 refine::score 的文档 —— 那里原先的说法与代码矛盾。

要修得动这 51 例,得把算子从"翻转一根键"推广成"取一个割点与它挂着的一个 连通分量,整体绕割点摆到 24 个栅格姿态之一"。它把端基重定向、开角、螺原子 处整块转都收编进来,而且只改割点处的一个键角(分量内部是刚体变换)。外部实验 测得这 51 例可以全部清零,代价是打分要加一级:重合数排在交叉之前(不加的话 它会拿"多一个假环"换"少一处交叉"),以及"键角等于理想值"这条口径要放弃 —— 那条现在没有任何 audit 判据守着,只有 chains::an_sp_atom_is_drawn_straight 碰它,靠"割点是 sp 原子就跳过"挡住。尚未实施。

键角被避让压窄

取代基避让按 30° 一档挪,挪两档就成了 60°。60° 不只是难看 —— 链上一个 60° 的拐角看着像旁边有个三元环,那是让人读错结构。实测氮芥的两条 N—CH₂—CH₂—Cl 臂上量到 60.1°。

试过硬性拒绝过窄的方向:窄角 334 → 227,代价是未解冲突 +494、干净率 −2.8 个百分点 —— 被拒的那个方向往往是唯一不撞的,拒了就换来一处碰撞。亏的, 没要。

改成只调顺序不拒绝:同样偏离一档时,先试角度更宽的那一侧。所有指标一起 变好,没有任何取舍:

改前 改后
窄角(<90°) 334 287
未解冲突 1189 1167
有键交叉 281 278
干净 91.3% 91.4%

剩下的 287 处来自消冲突的翻转,以及窄的那一侧确实是唯一空位的情形。

当前结果(large.smi,8831 个分子 × 2 套规范)

性质                     查到       违例
不出画布                17662        0
写法无关                17660        0
写法无关·比满             17646        0
写法无关·没查成                2        0
原子不重合               17662        2
楔形可读                  622        0
楔形落地                  622        0
环内双键                13600        0
线端不压字               17584        0
键角不过窄               17140        0
键长全等                17140        0

2026-08-30 重测,连着两次逐字节一致。

上面这张表先前是抄来的,不是跑出来的。 它写着 223 / 77 / 180,而 docs/dev/correctness.md 里同一张表写着另一组 257 / 76 / 287 —— 两组都不是 当时代码的输出,是几轮之前的数被人一路手抄下来的。抄的时候没人会红,读的人 看不出它已经过期。"当前结果"这四个字只有在它是重跑出来的时候才成立。

写法无关现在是 0 / 17660。判据报零,值多少全看分母,所以这一列要连着"查到" 一起读:比了 17660 对,其中 17646 对比满了全部写法,只有 2 个分子一对都没比成。 分母是 2 的零毫无意义。

那两处原子重合是同一个分子的两套规范(一个镍配合物,配体摆不开),而且它 已经报进 Depiction::unresolved —— 图自己说了画不好,不是装作画好了。

下面这段归因是在更早的一批(45 对)上做的。写法无关如今为 0,这段归因没有 现存的失败例可以复核,留着是为了记住当时是怎么定位的。

把"布局干净却画得不同"的那批(45 对写法,已用 RDKit 的 InChIKey 独立核对过 确实是同一个分子)逐对配上最合适的刚体变换再比,差距其实很集中:

配上刚体变换后还差几条线 对数 说明
0 2 纯粹是摆位不同 —— 规范朝向在自同构下的平局
1 35 一个取代基指了另一个方向,整张图跟着换了个姿态
2–4 4 同上,牵连的取代基多一两个
34 以上 4 全是多组分盐/配合物 —— 已修,分量的左右次序没定序

也就是说这不是"布局整体不稳":九成的例子归结到同一件事 —— 某一个取代基 挂的方向不同,而后规范朝向为了让新坐标序列最小,把整张图翻了个个儿,于是 "图元全不同"。

那一件事已定位到:环系统自身的坐标逐点相同,分岔发生在取代基挂上去的时候。 再往里查,place_neighbours 的四个输入(锚原子的规范秩、度、zig、待放邻居的 秩)在两种写法下完全一致,输出却不同 —— 剩下的唯一变量是 occupied(已放好的 邻居各在什么角度),而环系统的绝对朝向在规范朝向那一步之前并没有定死。 下一步该验这一条,尚未坐实。

顺带查出来的:图曾经每次运行都可能不一样

这个数字最早在 141/142 之间来回跳,而两次跑的是同一个二进制、同一份语料。 根子是标准库 HashMap 的哈希器每个进程随机播种,迭代顺序跟着变,而布局里有 按位置求和、取极值这类操作 —— 顺序一变,结果的末位就变,进而改变某个分支。

对一个出图库来说这是硬伤:同一张图重新生成一次就可能悄悄变样。已把 omgkit-depict 里的 HashMap/HashSet 全换成 BTreeMap/BTreeSet(按键定序), 连跑三次得到同一个数,这一整类不确定性消失。

单元判据抓不到这一条 —— 同一个进程里哈希种子是固定的,要跨进程重复跑才看得见。 所以这条只能靠上面那条命令跑两遍来守。

排除掉的假设(免得重查)

  • 不是浮点末位决定平局。 已把 place_neighboursoccupied 的排序改成 "量化后按规范秩打破平局"(这本身是对的,消掉了一类真实的不确定性),违例数没动。
  • 不是 BFS 路径奇偶决定的 zig 实测两种写法下锚原子的 zig 相同。

写出去的 molblock,我方自己读不回来

发 PyPI 之前拿丙氨酸随手自检,撞见一件事:

C[C@H](N)C(=O)O  →  我方写 molblock  →  我方读回来  →  C[C](N)C(=O)O
                                     →  RDKit 读同一份 →  C[C@H](N)C(=O)O

手性没了,而分子式看着一点毛病没有。 那时 39 道闸全绿、CI 全绿。

根子:价键字段说的是总价,不是"没有氢"

V2000 原子行第 48–51 列是价键字段 vvv:0 表示"按元素默认价补氢",1..=14 是 总价,15 是"零价"哨兵。

写出侧一直是对的 —— 方括号原子(作者钉过氢数)与自由基原子写上 vvv,把总价 钉死,免得读的一方按默认价乱补。丙氨酸的手性碳来自 [C@H],于是写出 vvv=4

读出侧只做了一半:看见非零就置 NO_IMPLICIT,没有把氢数算回来。于是那个碳 成了三根键、零个氢的三配位碳 —— 立体中心少一个配体,构型无从谈起。自由基碳 (vvv=3)同理,读回来是一个光秃秃的碳。

正确的换算是 氢数 = 总价 − 已经连出去的价,而"连出去的价"要等键块读完才算得 出,所以这一步推到属性块之后做。

为什么 39 道闸一条都没红

molblock 那一块当时有两条判据,各守一半:

判据 比的是
check_molblock.py 外部实现读我方写的块
check_molblock_read.py 我方读外部实现写的块

两条全绿,而我方写的字节我方自己读这条路一次都没走过 —— 那是第三条路, 有它自己的输入分布。具体到这里:外部实现写的 8831 条块里,136543 个原子只有 258 个带非零 vvv(都是自由基/异常价那一档),所以 check_molblock_read.py 几乎没被喂到这种输入;而我方写的块里,每个方括号原子都带。

补上的那条闸

check_molblock_roundtrip.py:同一份块,我方 parse_molblock 读一遍、外部实现 MolFromMolBlock 读一遍,规范串必须相同。走的是 Python 绑定 —— 也就是用的人 真正走的那条路。

这不是自反判据:比的两侧读的是同一份字节,真值仍由外部实现给出。

全量 8831 条:

逐条一致 8830;骨架对但立体不同 1(上限 1);读成别的分子 0
  我方画不出二维图 8;外部实现读不了我方的块 0
  参照侧带四面体的 310 条(下限 200);带顺反的 915 条(下限 100)

那 1 条是 C[P@H]C[P@@H]CS(=O)(=O)[O-]:我方读回来与输入 SMILES 逐字符一致, 外部实现读成 CPCPCS(=O)(=O)[O-],把两个磷的构型都丢了。分歧的方向是我方多 读出信息,不是读错 —— 与 check_molblock3d_read.py 那 16 条里的 4 条三价磷是 同一个感知边界,只是那边走三维坐标、这边走楔形。

变异标定: 把补氢那一步退回"只置标志不补氢",判据大片变红 —— 手性丢、 [NH3+] 掉成 [N+]。还原后 sha256 与原文一致。

后来发现三条还是不够:两个读者可以一起读出同一句假话

上面那条闸补进去之后不到一周,发 PyPI 前在服务器上又撞见一件事:8831 个分子 里 551 个(6.2%)写出去再读回来多了顺反标记,而原串里一个都没有。

原串   [O-][N+](=NC1=CC=CC=C1)C2=CC=CC=C2     ← 作者没写顺反
写图 → 那根 N=N 双键的立体码写的是 0
读回 → c1ccccc1/N=[N+](/c1ccccc1)[O-]         ← 冒出一个确定的构型

排版总得把取代基摆在某一侧,于是图上每根双键都有确定的几何。不标"未知"的话, 读的一方会把这个由布局随手摆出来的样子当成化学信息读走。

而"我方→我方"那条闸对此是绿的 —— 它比的是"我方读 vs 外部实现读同一份 字节",两个读者读出同一句假话时它照样一致。它守的是"两个读者不打架",守不了 "这份字节忠不忠于原分子"。

所以那节的结论要订正:每种交换格式要配四条判据,不是三条

# 判据 守什么
1 我方 → 外部 别人照我方写的能不能读对
2 外部 → 我方 我方能不能读懂别人写的
3 同一份字节,两个读者 两个读者读法一不一致
4 我方写的字节,忠不忠于原分子 有没有凭空多出/丢掉信息

第 4 条落在 check_molblock_roundtrip.py 的第二档:外部实现分别读原串我方写的块,后者数出来的顺反不得多于前者,上限 0;配下限"原串本来就带 顺反的分子数"(实测 366,下限 200),免得语料里一根顺反都没有时那个 0 是空的。

修法是写出侧用上 molblock 早就有的表达:交叉双键(键块第四列 3)。读的 那一侧我方本来就认它,写的那一侧没用上。实测 551 → 0,反向一条没丢。

变异标定:unspecified_cis_trans 退回"一根都不标",这一档报 551、退 1。

教训

方向是造信息,比丢信息更难发现。 丢了信息,拿到的文件缺一块;造了信息, 拿到的文件看不出任何毛病,只是多了一句作者从没说过的话 —— 而且下游会当真。

判据全绿的时候,先问一句"哪四条路,我走过几条"。

图特征描述符:check_descriptors.py

图神经网络读分子读的是一组固定的描述符。omgkit 交出 16 项(原子 12、键 4), 其中 13 项内核早就算好,新写的只有 Gasteiger 部分电荷,新查的只有 Pauling 电负性与同位素精确质量。

python3 harness/check_descriptors.py harness/corpus/large.smi \
    --extra harness/corpus/hard.smi \
    --extra harness/corpus/smoke.smi \
    --extra harness/corpus/descriptors.smi

判据走产品那条路:omgkit.parse_smiles(...).sanitize()atom_descriptors() / bond_descriptors(),与写特征化的人调的是同一串。自己 拼输入会漏掉净化与顺反折算,量到的是另一个分子。

当前(2026-08-29 实测,RDKit 2025.09.2):

逐原子/逐键比对 9051 条分子;分歧 0 处
  两边都拒绝(超价等,不计入分母):20 条
  刻意分歧(根因在参照侧,逐条钉死):7 条
  取值覆盖:杂化 8 种、手性 7 种、键级 5 种、顺反 3 种,全部走到
  计数:手性原子 514、顺反键 408、芳香原子 62600、环上原子 76790、带电原子 3066、
        无电负性原子 7、电荷算不出的原子 4192、同位素标注原子 21

四份语料一份都不能少

语料 它独有的那一档
large.smi 规模与真实分布
hard.smi 金属配合物、超配位、少见元素
smoke.smi 非四面体立体(@SP / @TB / @OH)—— 别处一条都没有
descriptors.smi 没有公认 Pauling 值的元素、Gasteiger 表外的金属、同位素标注

少喂一份,对应那几列就恒为同一个值 —— 逐位比对当然全绿,而"电负性整列返回 None""电荷永远有效""原子量永远读元素表"三种回归一个都拦不住。所以每一档 另配一条计数下限。

descriptors.smi本项目自写的,与 smoke.smi / hard.smi 同一种例外: 照着算法的假设挑,每一行攻击一条具体分支。加一行之前要答得上"这一行进来之前, 哪一条判据是空过的"。往 hard.smi 里塞是不行的 —— 那份语料同时喂着构型生成的 四条闸,加一个单原子稀有气体会连带动到那边的分母。

七条刻意分歧,根因全在参照那一侧

不是放水,三类都是本仓别处已经查明并记过的参照盲区:

条数 别处记在哪
自由基中心的手性(两根重原子键 + 方括号里一个氢) 4 differential_l3.rsNOT_CONVERGENT
丙二烯型轴手性 1 requirements.lock 里"为什么要引 Indigo"
超配位中心的四面体声称([S@](C)(C)(C)C[Xe@](F)(F)(F)F) 2 ——

这张表是双向的:多一条新的分歧红,少一条也红。后者逼着改的人回来确认 "消失"是对的,而不是把判据悄悄放松了一格。留着一条永不命中的例外,等于给判据 挖了一个没人看的洞。

分母:两边都拒绝 vs 只有我方拒绝

超价分子(F[Si](F)(F)(F)(F)F 这类)两边都净化不了。这不是覆盖漏洞,是两边 对同一个分子的一致判断,单独计数、不计入分母。只有我方拒绝才是漏洞 —— 那条分子的描述符一次都没被比过,而判据会因为少了一个分歧源而变好看。两者必须 分开数,合在一个"跳过"计数器里就分不出来了。

变异标定

每条都是拷出来改、跑完还原、shasum -a 256 -c 核过:

变异 判据
Gasteiger 参数表抄错一位(C sp3 的 b 9.18 → 9.19) 红,93381 处
总连接度漏掉隐式氢 红,76840 处
原子量不看同位素 红,21 处
双键顺反永远交 None 红,408 处
钉死表里塞一条永不命中的例外
喂空语料

够不着的那一档也写下来: 把氟的电负性从 3.98 改成 3.99,这条判据是绿的 —— 外部实现没有公开接口能读那张表(它埋在 OxidationNumbers.cpp 里),而拿 生成本表的同一份源码当参照是自证。那一项由 omgkit-corepauling_electronegativity_table 单元测试守(几个课本值 + 哪些元素没有值 + 有值的元素个数),它在 CI 主 job 里跑,不要 RDKit。

这条判据对电负性能看见的是另一件事:把没有公认值的元素补成默认 2.0, 它红 —— 因为那一档的计数下限被清空了。能看见"整档消失",看不见"值错一位"。

标定脚本自己坏掉那次

头一版的标定脚本把四份语料塞进一个变量又加了引号,整串被当成一个文件名, 判据每次都是 FileNotFoundError 退 1。于是连着三条变异都"红了" —— 而其中一条 (给没有电负性的元素补默认值)当时根本没被那条判据看见。同一次里还有一处: && 串起来的 maturin + pip,maturin 失败时 pip 被跳过,上一次变异的 wheel 还装着,量到的红是上一条留下的。

退出码只说"红了",不说"为什么红"。工装自己有 bug 时它红得比被测代码还积极, 而那种红会被读成最想要的结论 —— 所以最不容易被怀疑。

现在的做法:每条变异先跑一遍不变异的基线并确认它是绿的(基线红说明工装 坏了),再核红的那几行说的是不是这条变异,还原之后 sha256 核一遍。

两处口径,当时是错的,判据当场照出来

原子量带同位素。 外部实现的 GetMass() 标了同位素就给那个核素的精确质量, 氘是 2.0141 不是 1.008 —— 差了一倍。头一版按"标准原子量,不随同位素标注变"写, 语料里 10 个氘代分子当场把它照了出来。为此引进了一张 3111 条的同位素质量表 (同样由 gen_elements.pyatomic_data.cpp 生成)。

生成器解析那一段时踩了个坑:六十多个 R"DAT( 块里有两个的首行数据与开括号 写在同一物理行上,不抹掉括号就会静默丢掉 H-1 与 Ca-47 —— 丢了不报错,只是氘查 得到、氕查不到。补了一条跨表自查:每个有同位素数据的元素必须含它自己的 "最常见同位素"(那个质量数写在元素表那一行里,不在同位素表里)。

容差不该用来掩盖自家的存储宽度。 原子量那一列先前配着 MASS_TOL = 1e-5, 注释写得很像回事:"本实现的元素表存的是 f32,12.011 读回来是 12.0109996…, 这是存储精度,不是分歧"。那句话是真的,可它是个可以修的原因 —— 那两个字段 在核心里只有描述符一个使用方,改成 f64 之后两边是同一串十进制文本解出的同一个 double,容差直接收到 0

容差一旦立在判据里,它挡住的就不只是它声称挡的那一档:1e-5 同时让"三千行表里 抄错一位"变绿。对照着留下来的是电荷那条 1e-12 —— 它的成因是邻居求和的次序, 不是存储宽度,改不掉,而且实测差距在 1e-17,容差比它宽五个数量级。

run 的空列表原先有两个意思

发现的路子是查描述符那边顺手翻反应基准:byproduct_bench.py 的统计里挂着一档 「无输出 689 条」,从来没人追。它与主基准的口径不同 —— 主基准枚举输入的全部 排列再取并集,这个脚本按记录里分子本来的顺序直接调一次 run

抽样前 4000 条正向,逐条核:

按记录顺序 run 交白卷            59 条
  └ 换个递入顺序就能出产物       59 条   ← 100%
  └ run_on_substrate 也能出      59 条
  └ 两条路都出不来                0 条

59/59,一条都不是真的匹配不上。

于是空列表同时表达两件事,而调用方分不开:

空列表的意思 调用方该做什么
这批分子上没有反应位点 换模板 / 换底物
你递入的顺序与模板片段对不上 换个顺序,或用 run_on_substrate

第二种是静默的错答案:拿到空列表的人会得出"这条反应跑不了"的结论,而它是 错的。文档只写了"数目不符时返回空",没写"顺序不对也返回空"。

修法:契约改成"一一对应",不是"第 i 个配第 i 个"

run_reactants 先试恒等分配(顺序对得上时开销与先前完全相同),给不出产物才 去找别的一一对应,按字典序取第一个能出的。匹配表也是到这一步才补满,快路一分钱 不多花。

多个片段落在同一个分子上仍然不归它管 —— 那是分子内的形状,由 run_on_substrate 承担。回退只在"一一对应"这个范围内找,不会凭空放宽模板。

实测

全语料按记录自带的分子顺序直接调用:

修前 修后
正向交白卷 689(抽样外推) 0 / 49719
逆向交白卷 0 / 50012

耗时 A/B(同一份语料、同一台机器、各取 5 次最小值):

中位 p90 p99 五万条合计
修前(纯位置式) 15.92 µs 42.50 349.83 1.532 s
修后(带回退) 15.83 µs 42.17 344.75 1.519 s

全在噪声里。回退只在本来就要返回空的那 1.4% 上走,而那 1.4% 先前交的是白卷。

命中率不受影响:主基准枚举全部排列再取并集,并集没变。变的是"按自然顺序 调用一次"这条路 —— 也就是真实调用方走的那条。

变异标定

判据在 omgkit-match/tests/reaction.rs(拷出来改、跑完还原、sha256 核过):

变异 判据
把回退整个删掉(退回纯位置式) 红 —— the_order_the_molecules_are_handed_in_does_not_decide_whether_it_runs
回退改成把所有分配的结果并起来 红 —— the_fallback_takes_the_first_assignment_not_the_union(同一条反应数了两遍)

另一条 falling_back_to_another_assignment_does_not_invent_a_site 守反方向: 两个都是酸、没有胺时仍须返回空。少了它,一个"只要有产物就交"的实现照样全绿。

够不着的那一档写下来:"恒等分配有没有走快路"这三条判据都守不住 —— 搜索 本来就从恒等分配起步,把快路删掉结果一样,只是慢。那件事只有耗时判据能守, 上面那张 A/B 表就是它的全部依据,而它不在 CI 里

三维分子图:判官读的是 SVG,不是我们算的中间量

check_depict3d.py。导出(omgkit-depict --example dump_depict3d)交出去的是 产品真正吐出来的那段 SVG,外加坐标、旋转矩阵、元素表与键表。判官从 SVG 里 把 <circle><line> 读回来,再用 numpy、RDKit 的范德华半径、以及 params/jmol_colors.tsv 独立算一遍该是什么样。

导出里也有 placed(每个原子落在画布哪里),但它不当真值 —— 那是我们自己 算的。它只被判一件事:与图里的圆对不对得上。那是个公开 API,下游拿它在图上加 标注,错位了标注就贴到别的原子上。

外部真值分别是谁

判的东西 外部真值
主轴(视角) RDKit ComputeCanonicalTransform(ignoreHs=False),并且 numpy eigh
球半径 RDKit 周期表的 GetRvdw
元素颜色 params/jmol_colors.tsv
正交投影、深度序、并排方向 numpy 独立复算

RDKit 与 numpy 是两条独立的路,判官第一件事是核它们互相吻合(实测在 非简并方向上逐根 |cos| = 1)。两者打架时谁都不能当真值,当场退非零。

样式的那几个数,判官自己存一份

起初判官是从导出的 jsonl 里读 ball_vdw_frac 的 —— 那是被测的常量, 一改两边一起动:实测把球棍的球半径从 23% 改成 25%,判官一声不吭地全绿。 现在四套样式的四个数写在判官里(出处是 Jmol 文档),与导出的值先对一遍, 不一致就报"样式表被动过"。

三处判官够不着、或者只能判到一半的地方

  1. placed 只能判到半个百分位。 SVG 里的坐标是 {:.2} 写出去的,图里 就只有那么多位。这不是容差放松,是这条路的固有上限。
  2. 深度差在 1e-4 Å 以内的球对不判次序(实测 400 个分子上有 2576 对)。 实现按量化过的深度排序,比这还细地追究"谁在前面",量的是浮点噪声。 门槛不引用实现里的量化精度 —— 引用了就一起改,变异永远打不红。
  3. 投影上完全重合的原子只比多重集,不判组内次序(实测 400 个分子里 9 个 分子、196 个原子)。分子有一张镜面而主轴恰好把它摆成屏幕平面时,镜面两侧的 原子投影到同一点 —— 三维坐标一个都不重合,是投影重合。那时"谁盖着谁"在图上 本来就看不出来。

一段绕过的路:两个氢差 0.0018 磅

判官起初按"坐标四舍五入到 0.01 磅"给原子分组。撞上一个分子,两个氢的投影相距 0.0018 磅(图里根本分不开),而它们恰好跨在格点边界两侧 —— 被分进两组,一组分 到两个圆、另一组一个都没有,判官报"认不回原子"。改成按距离聚簇,门槛就是 SVG 的两位小数。

变异标定

拷出来改、重跑导出与判官、再还原,sha256 核过。

变异 判据
碳改成 Rasmol 的灰 #c8c8c8 红 —— 颜色
球棍的球半径 23% → 25% 红 —— 样式表比对(修掉自指之后才红)
画家算法反着排(近的先画) 红 —— 深度序;three.rs 的单元判据也红
视角第三轴取负(行列式 −1) 红 —— 真旋转
键的两半同色 红 —— 半根键的颜色
多重键并排方向不投到屏幕平面 红 —— 并排起点
二阶矩累加次序退回按秩 红 —— 重编号逐字节相同

最后那一条只有全语料抓得住:three.rs 里十个分子的单元判据够不着它 (那十个分子上按哪个次序累加都给出同一个和)。这正是"分子多少不是关键, 语料的结构才是"的又一例 —— 只是这一次缺的是规模,因为要撞的是浮点末位。

单元判据那一侧补的两个洞

判官读的是圆心、半径、颜色、画序,看不见"这个圆在背景上分不分得出来"。 把球的暗描边改成球本身的颜色,全语料判官全绿 —— 而纯白的氢在白底上就此消失, 连着它的键也消失(键的那一半同样是纯白)。补的是 svg.rs 里的 纯白的原子与键在白底上分得出来,球与圆柱两边各钉一下,两个变异都打红。

另一个:three.rs 的"半棍从球面起步而不是从球心"起初拿被测的 trim 函数算 期望值 —— 把 trim 整个乘 0,这条判据一声不吭(只有 trim 自己的单元判据红)。 判据的期望值不许引用被测函数。 改成按几何直接写(轴过球心时交点离球心正好 一个半径)之后,同一个变异两条判据一起红。