差分测试基础设施(全文)¶
每一条判据守的是什么、怎么证明它不会空过、覆盖多少。仓库里的工程记录原文,中文。
每一层都要能对全量语料跑一遍逐字段比对。"分歧数"是头号 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 module。oracle_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-cleanup 与 l2-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 专门盯增长曲线,判据是每原子耗时不随
规模上升。
已知的复杂度陷阱都是同一个形状:在"按原子/按环系"的循环里,做了一件正比于 整个分子的事。
编写这类测试有三条硬要求:
- 规模要够。 平方项要到几千原子才占主导,规模太小时缺陷淹没在噪声里。
- 交错测量。 顺序测(小的先、大的后)时最小档会系统性偏慢 —— 线程起初 落在能效核上、频率还没爬满 —— 足以把曲线整个翻过来。
- 形状要够。 分量内算法的复杂度只在"单个大稠合体系"上暴露;"很多个小 环系"那种形状里每个分量才 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 写出的规范串,判据是
外加三列直接比:能不能读、去氢后的原子数、键数。冒烟语料 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)O → O=[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 那一档可以整个交给外部实现独立算 —— 断口全是单键、不涉及电荷、空价数
正好等于要补的氢数时,没有选择余地,只有"在断口切开、断头补氢"这一件事可做。
当前:25849 条比过,0 分歧。注入缺陷验过判据有牙齿:翻手性 4/4、删原子 2527/2527、多补一个氢 1452/1452,全部抓到。
翻手性只注得进 4 条,这个数本身是结论:两万五千条闭合副产物里带手性中心的
只有 4 个。所以"切点落在手性中心时要重定基"那条缺陷在这份语料上照不出来,
它是靠构造判据立住的(见 omgkit-match/tests/byproduct.rs 的
a_stereocentre_at_the_cut_is_rebased)。判据覆盖不到的地方,要自己说出来。
还没有判据守着的几处¶
判据覆盖面要自己说出来,不然"全绿"读起来像"全都验过了"。当前明确没有判据的:
| 没守住的 | 为什么难写 |
|---|---|
form_bonds 的三级优先级(提键级 > 跨分量 > 成环) |
要分辨它得有 ≥3 处空价,而那种形状上 borrow_hydrogens 会从同一个原子连摘两个氢,结果由摘氢而非优先级决定 —— 造出来的用例判别的不是它声称判别的东西 |
apply_charges 的元素优先级(卤素 > 氧硫 > 氮) |
总量由预算定死,落错原子只改写法不改账;要判别得构造"两个候选元素不同且写法可区分"的片段 |
MAX_BONDS 这个上限本身 |
它是设计决定,不是可观察行为 |
build_fragment 清 stereo_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.rs、dump_conformers.rs、
bench_conformers.rs、tests/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 那边 MolFromSmiles 与 AddHs 在计时
之外,我方这边解析、净化、感知顺反、补显式氢也在计时之外。同一台机器、
同一份 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.smi 与 hard.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,外加已知的钍配合物)。
large 与 hard 上分别是 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 完全不支持丙二烯立体。逐条实测:
| 路径 | 结果 |
|---|---|
MolFromSmiles → MolToSmiles |
@AL1 与 @AL2 给出同一串 |
FindPotentialStereo(新立体感知) |
只报两根双键,不报轴 |
rdCIPLabeler.AssignCIPLabels |
一个标号都没有 |
EmbedMolecule → AssignStereochemistryFrom3D |
丢 |
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/F → F\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 个分子里红一条:
判官的参照那一侧用的是 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_smiles 与 smiles::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 build 再 pip 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 build再pip 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、另一侧是
本实现,版本一换就没得对消 —— 见下一节。
追命中率的前提是真值本身是对的¶
真实语料的"真值"是人记录下来的,不等于化学事实。反向应用模板时最常见的一种 落差是真值欠定:记录里的反应物没标构型,而底物完全定得下来。这时产物带上 构型是对的,拿"完全命中"去要求它等于欠定的真值,等于逼实现丢信息。
实测有一条正是如此:
按字面比,这条算"未命中",而且外部实现"命中"了 —— 它是靠丢掉几何命中的。 把这种情形算进待修缺陷,只会把实现往错的方向推。
所以未命中要先分档,真值欠定的那一档不追。判据是"真值 ⊑ 产物":拿真值当
查询、产物当底物做子结构匹配并开 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:
- 数值复算。 用产品的
chiral::center_volume,按真值给的配体序、在真值给的 坐标上复算,必须与vol逐个相同(容差 1e-9,实测最大差 2.7e-15,269 个中心)。 同一份输入、同一个定义,Python 与 Rust 两个独立实现必须给出同一个数。 - 中心在配体四面体里面。 正四面体上
V_配体 = −4·V_中心,恒反号;同号意味着 中心原子被挤到四个配体张成的四面体外面(伞形翻转),那时sign说的构型与 分子实际的构型已经对不上。244 个四配位中心全部反号。dump 在导的时候会数这一档, 但那是导的那一刻的事,入库之后没人再看。 - 号不能压在近乎共面的中心上。 无量纲平度
|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_reactions 与 oracle_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::stereo 的 MIN_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_smarts 与
check_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 会失败而不是警告。
--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,逆合成模板,每条自带两侧的
正确答案 —— 模板作用在产物上应当给出反应物,把模板两侧对调作用在反应物上
应当给回产物。
两侧必须同口径,否则数字是假的¶
产物生成出来的图两边都是未净化的。直接把未净化的图写成 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.rs 的 products_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.rs 的
bond_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.rs 的
a_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)跑得再多也照不到这段代码。真实模板里这却是常态:
立体专一的反应正是靠产物侧的 @ 表达构型的。用例因此写死在判据文件里。
区分力靠"产物随不随底物变"¶
用例成对给出对映体底物:"新建"那一档两个底物必须给出同一个产物,"保留" 与"翻转"那两档必须给出不同的产物。不满足就是空过,单独报出来。
邻居"挪走了"也算腾出槽位¶
重定基要把反应物的邻居序换成产物的邻居序,中间要判每个槽位空没空。判据是 还连不连在这个中心上,不是那个原子有没有进产物 —— 反应可以把一个邻居挪到 另一个产物片段去、同时接上一个新的:
氧带着映射号活到了另一个产物里,只是不再连着 :1。只判"进没进产物"的话这个
槽位算被占着,于是反应物侧的邻居集合里有个产物侧没有的原子,置换的多重集对
不上,重定基被静默跳过 —— 标记原样照抄,得到镜像。
这一条在真实语料上一次就修好 6 条("纯立体、外部命中"从 7 条降到 2 条)。
判据必须是绝对的,还要挑置换为奇的用例¶
写这条 Rust 测试时空过了两次,两次都是绿的而缺陷还在:
| 空过的写法 | 为什么看不出来 |
|---|---|
| 拿"模板不写手性"当参照 | 它走同一段重定基,同一个缺陷把两边一起带偏 |
底物写成 CO[C@@H](C)CC |
走掉的氧与接上来的氧占同一位置,置换是偶的,漏掉重定基也无妨 |
正确的写法是直接写出应得的产物,并且把醚氧摆在中间那一位
(C[C@@H](OC)CC),让置换成奇。
当前状态¶
四种指令 7 条、真实模板形态 5 条,全部一致,0 空过(--max-bad 默认 0)。
顺反那 46 条:模板说不出的,谁都造不出¶
反应侧还剩 46 条"抹掉顺反就完全命中"的。要分清两件事:那根差的双键是模板新建 的(底物那里根本不是双键,几何无从继承),还是从底物搬过来的(几何本来是确定 的,丢了就是实现的问题)。
按映射号对判"新建还是搬运"会漏¶
第一版判据只认两端都有映射号的键:把反应物模板与产物模板各自的映射号对收成 表,产物侧有、反应物侧没有的算新建。模板里这种写法就漏掉了:
双键的一端是模板新加的原子,没有映射号,于是这根键根本进不了表,"新建"里查不到 它,反倒被算成"搬过来的"(反应 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 的模板
里,中间两根双键两端都有方向,首尾两根只有一端。逐根一看:两端都写了的两根, 两个实现都造出了 E;只写一端的两根,两个实现都留空,规范式逐字相同。这比"没有 反例"强 —— 它同时证明了写全了就落实得下、写不全才落实不下,判据没有空过。
带映射号与不带映射号是两条路,不能对着比¶
诊断脚本一度多报了 6 条"手性被翻成镜像"。查下去:同一个分子,带映射号解析与不带 映射号解析,外部实现给出的规范式落在两种等价写法中的不同一种 ——
两串的 InChI 一模一样,是同一个化合物。降冰片烷桥头那对中心只有相对构型有意义, 映射号打破了分子的对称性,"这算不算真手性中心"的判定跟着变。
所以要在文本层把 :N 去掉再解析,而不是解析完再清映射号属性——后者留下的残余
状态足以让写出器换一种写法。去号后原子次序逐字不变,两次解析的下标一一对应,可以
拿带号那份查号、拿不带号那份比化学。
这个坑有个好抓手:修之前总数 52、按档只加到 36;修之后总数 46(与旧口径逐条对齐)、 46 条全部落档。账对不平就是判据有问题,不要当噪声放过。
两侧都写手性:比标记之前要先对齐邻居次序¶
四种指令(继承/清掉/写死/保留-翻转)本身早就对齐了,可"保留还是翻转"这一步原先 只比两侧的标记字面值:
两侧都是 @,取代基一个没换 —— 按字面比是"保留"。可产物模板把 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_hs 与 NO_IMPLICIT,它要保住这个差别;规范化要抹掉它。
所以不能挑一个,得两个都留着,由 smiles::write(忠实)与 canon::canonical_smiles
(规范)各取所需。
"去框"的判据只要三样,不要价键模型¶
原先的注释说这需要 L2 的价键模型、L1 拿不到,所以推迟。这个理由是错的:判据只要 键级和 + 总氢数 + 该元素的首位默认价,三样都在 L0。
- 带氢时:
键级和 + 氢数 == 首位默认价—— 反推出来正好是这些氢 - 不带氢时:
键级和 >= 首位默认价—— 价已填满,反推出来也是 0 个
第二条不能省成"没氢就随便去框":三价的中性碳([C])氢数是 0,去框写成 C 再读
回来就补上一个氢,分子当场变了。
两条护栏:只去框、不加框(加框要知道简写形式会推出几个氢,未净化的分子上那个数 还没算出来);算不准的一律留框(碰到配位键、无价约束的元素直接留)。留框只是 啰嗦,去错了会改掉分子。
判据的洞不是判据松¶
check_write_fidelity.py 与 different_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,缺口归零。
三条可复用的:
- 归因表必须与总数对账,并且把对账写进脚本(
summarize.py现在算miss_attribution_gap,非零就报警;拿修前数据跑会报"有 2 条没被归因", 所以对账自身也非空过)。留给人去加一遍,就等于不加。 - "比不了"要与"比过了、没问题"分开记。 静默
continue会把前者伪装成后者。 - 收紧比放宽更难发现。 放宽会多出错答案,一眼看得见;收紧只是少出答案, 而少出的那些在命中率里表现为"未命中",混在几百条数据问题里毫不起眼。 与第七条同一个形状:存在量词看不见的东西,都要另配一条计数判据。
出图这一层的硬性质(omgkit-depict)¶
判据只守得住可判定的性质,守不住"这张图好不好看"。可判定的这六条要对全量 语料跑,不抽样 —— 单元判据里的分子是手挑的,而手挑时心里已经有一个模型,挑出来 的正好是那个模型覆盖得到的。实测吃过两次亏:"环内双键"那条列了八个分子全是单环 或邻稠的,桥环共用键整类漏了出去;"楔形窄端"那条列的三个分子都不含 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 写法再画一遍来验。换写法的办法是给写出器一个 优先序,先前那个优先序是拿乘法哈希凑的:
它在很多 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.rs 的 regenerate_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.rs 的 Quality 文档里:骨架上同样 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 守着),但那个碳画出来什么都没有 ——
顶点处没有拐角,两根双键的第二条线又都偏在同一侧,整张图读起来是顺式二烯:
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%)。这一档不是渲染的问题,是布局的问题,记在这里等着修。
挂在环上的臂朝哪边伸:不能盲选锯齿的符号¶
阿司匹林画出来乙酰基整条臂折回去贴着苯环。量它的键角:
原子 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::trim的box_reach从"居中盒的射线长度"改成"从盒内一点出盒", 而且两端要各算各的 —— 盒不再左右对称,一个值两边通用不成立了。refine::radii暂时没改,见下。
全量语料:干净率、未解冲突、键交叉、写法无关一项没动——这一改在硬指标上 是免费的,买到的纯是观感。
判据 the_element_symbol_sits_on_the_atom_not_on_the_string_centre 从 runs
独立重算符号该落在哪,不复用 build 里的中间量。变异验证:把 dx 归零,
它当场报 元素符号 O 的中心离原子 -0.2507 个键长 —— 与上表算出来的一致。
一处没做完的,要说清。 refine::radii 仍按"盒心在原子上"取外接圆,于是
长的那一侧被低估约 dx、短的那一侧被高估同样多。试过改成"原子到盒最远那个
角":各向同性地取最大,长边诚实了、短边跟着过估,干净率 91.6% → 89.3%、
未解冲突 +401,亏的。正解是碰撞判定直接用偏心的盒对盒而不是一个各向
同性的半径 —— 那要改 remaining 的判据本身,记在账上。
键线在标签外停得太远:按外接圆切,不按盒边切¶
键线要在原子标签外停住,免得线划过字。先前切的是标签盒的外接圆
(half_w.hypot(half_h)):
圆包住盒,安全,但在盒窄的那个方向上白白空出一大段。竖直方向去接一个横向
宽的标签,差的正是 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::doubleBondEnd 的 trunc 参数取
!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 的 calcDoubleBondLines 把
isLinearAtom 与端基放在同一个分支里,口径一致"。真正的条件是
—— 端基进对称支的前提是两端都是端基。单端端基另走 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=O、C=C、CC(C)=C、CC(C)=O,外加 CN=O(内侧带标签)与一个砜(内侧度 4) |
a_terminal_double_bond_closes_the_joint_at_the_inner_atom |
顶点合得拢、第二条偏向内侧原子的另一根键那侧、末端齐头、内侧那头斜切到角平分线上 |
两处判据是被复审逼出来的,原样记下:
一、CC(C)=C、CC(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 一致 —— 那正是最初提出 这个问题的那张图。
下标空间是这次最容易出事的地方¶
被画的分子比传进来的多几个原子,而 coords、wedges、unresolved、
crossings、unwedged、degraded 的下标全部相对补完之后的编号。方案审核
把这一条列为阻断级问题,实测过越界的 unresolved 32 处、落在 C–H 上的
wedges 298 条。
按审核的建议做成 Depiction::drawn(&mol) -> Cow<MolBuilder>:没补东西时借用、
不复制,渲染与判据一律拿它。这样每条判据只要把 m 换成 d.drawn(&m) 就
自动覆盖补出来的氢,不用为氢单写一套并行逻辑。
写法无关那一条是例外,而且不能想当然。 它要把分子重写成 SMILES 再画一遍
—— 拿补完的去写,显式氢会进到 SMILES 里,改写出来的就不是同一个分子了。所以
checks 同时收 orig 与 drawn,文档里写清楚哪条用哪个。
判官自己先漏了 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_gap 拿 if 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 仍是最大一块。插桩看到:
6.2831853…534 是 2π − 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 前面,
两件事混在一起,分不出是哪一处在起作用。所以这里不再对"same 与 crossed
谁在前"下任何结论 —— 没量清楚的话就别写。
规范指纹漏判 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_pt 与 bond_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_direction 的 pick 加了一句"赢家是窄角就把整张候选表打出来",
第 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→x 与 c→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 行两张),结论出乎意料。
不是布局摆坏的¶
第一刀就问对了地方:分阶段数重合。
是顺反校正造出来的。 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 条 η 配位 ——
NotInTable 与 NoFingerprint 都是 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 上碰巧绿。它们不是失败,是悄悄变成空过 —— 那正是隐患的定义:
判据还在,已经不查东西了,而且没人会知道。
抓到两条,就该问还有没有第三条。
筛法¶
depict 有 165 条判据。风险类是「断言写在循环里,而循环可能被 continue /
filter 整条跳过,又没有任何"跑没跑过"的计数」。脚本筛出 17 条,逐条读下来
其中 6 条是误报(断言在循环外、或已用 assert_eq!(compared, 12) 之类守着)。
真要补的 11 条。
没有去猜哪条已经空了 —— 补完让测试自己说。 补上的当场抓到两处:
萘的合拢键在它那个 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(一个键长塞不下O—N⁺(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 行):一个键长的间距塞不下O—N⁺(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 行:
两个苯环,改动前不违例。不是"三个环内邻居"那一路:给 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_… 是关键一条:ink 由 build 算、落笔由 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(消冲突半径)仍用它。两件事:
- 横向上墨迹盒一定在外接盒之内(前进宽度含左右边距),判据断言了这一条。
- 纵向不然。
Hg、Ag、Np、Dy的下伸部比名义字高多出 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_with的None分支直接改成返回(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 的输出序,依赖存储序。
交换 u、v 在非平局时不改选边:两个候选环心跟着互换,而"取远离已放置
质心的那个"仍然选中同一个点,转向也自适应。
可一旦平局,两个候选心到质心等距,代码走 else 取 c2,而 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)。先前写「四次全中」,不准确。
修法与结果¶
三件事,不是一件:
- 调用处把
(u, v)按规范秩定序 —— 定死c1/c2谁是谁; - 质心按规范秩累加 —— 它是一串浮点求和,次序一变末位就变;
- 用有符号投影显式判平局(
|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° 旋转 × 镜像)归一
不掉它。往前追:
布局是干净的,分岔出在消冲突。 而同一分子 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 里临时加判据,拿阿司匹林量,量完删掉):
| 轴 | 0° | 15° | 30° | 45° | 7° | 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)。
先前这里写着「单元判据写不成:小分子上构造不出那种情形」。不成立 —— 正丁烷就够(
CCCCvsC(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::scene 用 wedges.get(bi).unwrap_or_default() 静默兜底;
examples/dump_molblock.rs 用的是 d.wedges[bi],只要哪个 η 配合物带一个
立体中心就直接越界 panic。
修法:逐原子/逐键的东西一律拿摘细前的那一份(radii、fix_cis_trans、
assign_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 写成规范风格的串:
骨架逐字相同,每一个立体标记都翻了 —— 两套标号差的是一个反自同构 (镜面),不是自同构。若真是自同构下等价,两串会完全一样,那么"遍历起点取字典 序最小"也同样分不开,换秩就没有意义了。正因为不等价,取最小串消掉了这个自由度。
走过的一条弯路:直接翻转楔形¶
先试的是"把整套楔形翻过来,若描述的还是同一个分子,就由规范秩挑一套"。
写法无关确实 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 序不是那个意思。
所以分两级:主键仍是细化出来的类(结构语义原样保留),只有类内那点任取 被换成规范次序。
全量对照¶
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_ranks 的 break_all_ties 本身规范化 ——
标准办法是每个平局逐个试、取最小证书。那是 omgkit-io 规范化核心的算法改动,
要连它自己那套差分一起验,不在本轮范围内。本轮做的是在出图这一侧不依赖那层
任取,代价为零。
消冲突为什么只能用圆:两次实测把「偏心盒对盒」这条路封死了¶
refine::radii 给每个原子一个各向同性的碰撞半径 hypot(half_w, half_h),
而标签盒明明是个偏心的矩形。代码里挂了很久一句"正解是碰撞判定直接用偏心的
盒对盒……记在这里等着做"。这一节把它了结:那不是没空做,是流水线不允许。
一、refine 那个坐标系里的"水平"不是最终的水平¶
标签在画布上永远是轴对齐的。可 generate 的顺序是
canonicalise 在 30° 的倍数里挑姿态(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° 会从 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_with 和 generate_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):
打点查到 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 的质心:
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_component 里 of_atom 把"这个原子属于哪几个
系统"堆成一个 Vec,堆的顺序就是 fused_ring_systems 的返回顺序 ——
双连通分解的遍历顺序,也就是存储序。实测两种写法:
三条螯合环长得一模一样,所以谁先摆决定了它们绕着镍怎么分布,摆出来差 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_from里settle的bonded(键的存储序,力按这个次序累加) —— 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 挑最好的。三个根本毛病:
- 它不解问题,它抽奖。 morphine 的 0 交叉解要搜 22.4 万次才撞上;立方烷、 棱晶烷等 5 条骨架跑 200 万次仍然到不了。
- 键长和键角没有任何保证。 松弛只把键长"拉向"1,退化布局实测偏差全部 ≥20%、常见 30%~60%。
- 打分口径一调就在别处砸坑。 实测给
Quality加一档几何可行性阈值 (偏差 >0.35 不参与少交叉的竞争):morphine 如愿退回好看的那版,但 金刚烷(语料里最常见的桥环骨架,出现 18 次)从 0 交叉变成 1 交叉, 棱晶烷从 1 处交叉变成 15 处。不是阈值没调好 —— 拿一个标量去排序一堆 随机解,本来就没有稳定的偏好结构。
读了 CoordGen 的源码(RDKit 用的就是它,BSD 开源)¶
CoordgenFragmentBuilder::generateCoordinatesCentralRings 的真实流程:
几处我原本理解错、读了源码才纠正的:
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 个含"零个新原子"的环。
量出来的:好得多,但有一处硬伤¶
把弧法当额外候选接进离线生成器(best 按 Quality 取最小,多一个候选只
可能更好),重跑表:
| 模板表 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,HO 与 N 两个标签叠在一起,键交叉四处
——一张读不出结构的图。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 处,这是 下限"。别人的算法也做不到,不等于做不到。
真正该问的是这个骨架图是不是平面图:
是平面图,按 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 个是真
破口:sum 按 mol.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(符号一条、氢一条),Primitive、svg.rs、
raster.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/dy 由 build 算,而 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。而LabelPlace与label_for都是公开的,调用方给什么由不得我们。改成回落横排。
refine::radii 的注释数字也修了:竖排外接圆是 0.836 em 不是 0.849。实测
横排 0.806 —— 竖排几乎不改外接圆(+3.7%),它做的是把各向异性转了 90°。
盒子窄了一半,横着的键因此宽松 —— 先前方案里"竖着的键会更挤"那句因果是反的: 竖排多出来的高度长在背离键的那一侧,沿键方向的净空基本不变。
一个符号错,盒上下对称时看不出来¶
label_clearance 的 dir 该用哪个坐标系,当时想当然以为 trim 在画布上跑,
加了一句 up = (d.x, -d.y)。trim 其实一直在布局坐标里跑 —— scene 是
先 trim 再 to_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-io 的 perceive_bond_stereo 与 BondData::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 也单列
(calcDoubleBondLines 里 atomLabels_[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_clearance 与 render::is_squeezed,阈值收成一个 SQUEEZE
常量,四处都调它。判据要量什么,就调实现里量它的那个函数。
判据的覆盖数也跟着改了:先前 线端不压字 每个分子无条件记一次"查到",可全量
里有 86 个分子×规范一个端点都没得比(没有落在非 tight 键上的带标签端)——
那时它是空过的。改成按"真比过"计,17662 → 17576。
还加了一条断言锁住图元与键的对应:判据按 drawn_orders 数着消耗图元,将来
哪根键多发或少发一个(芳香圈、配位键、跨过交叉点断成两段),它就会静默错位、
拿这根键的线去比另一根键的标签,而且照样报绿。现在按键消耗完之后剩下的必须
正好全是标签,全量 17662 处逐一验过。
画布没给补出来的符号留地方¶
bounds 只问 label_for。可共线的骨架碳是 scene 另外补的符号(两根键
连成一条直线时顶点处没有拐角,不补的话图上根本看不出那里有个原子)—— 画布
不知道它存在,CH 的半宽 7.22pt 直接戳出画布右边。全量 4 处。
label_at 把"这个原子真正会画出来的标签"收成一个函数,scene、bounds、
判据三处都调它。这已经是 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_neighbours里occupied的排序改成 "量化后按规范秩打破平局"(这本身是对的,消掉了一类真实的不确定性),违例数没动。 - 不是 BFS 路径奇偶决定的
zig。 实测两种写法下锚原子的zig相同。
写出去的 molblock,我方自己读不回来¶
发 PyPI 之前拿丙氨酸随手自检,撞见一件事:
手性没了,而分子式看着一点毛病没有。 那时 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.rs 的 NOT_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-core 的
pauling_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.py 从 atomic_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 条正向,逐条核:
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 文档),与导出的值先对一遍,
不一致就报"样式表被动过"。
三处判官够不着、或者只能判到一半的地方¶
placed只能判到半个百分位。 SVG 里的坐标是{:.2}写出去的,图里 就只有那么多位。这不是容差放松,是这条路的固有上限。- 深度差在 1e-4 Å 以内的球对不判次序(实测 400 个分子上有 2576 对)。 实现按量化过的深度排序,比这还细地追究"谁在前面",量的是浮点噪声。 门槛不引用实现里的量化精度 —— 引用了就一起改,变异永远打不红。
- 投影上完全重合的原子只比多重集,不判组内次序(实测 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 自己的单元判据红)。
判据的期望值不许引用被测函数。 改成按几何直接写(轴过球心时交点离球心正好
一个半径)之后,同一个变异两条判据一起红。