设计(全文)¶
逐层的设计取舍与验证方式。这是仓库里的工程记录原文,中文。
源文件:docs/design.md
本文记录 omgkit 各层的设计取舍:每一层做什么、为什么这么做、哪些约定不能动。 面向的是要改这份代码的人。只想用的话看 README 就够。
本文不放测试成绩。 各层都有差分测试守着,判据怎么写才不会空过、覆盖面怎么
算、基准怎么生成,都在 harness/README.md;那里的数字
连着能跑的命令,可以自己重新量。写在这里的数字只有一种:用来解释某个设计
决定为什么成立的那些。
核心设计¶
分子存为列式批而非对象图。常见做法是邻接表加堆分配的原子对象,遍历一个 40 原子的分子就是 40 次随机访存。
MolBatch 把同一属性的所有原子放进一个连续数组,邻接用 CSR。这一个决策同时
买到:顺序访存、零拷贝导出 numpy/Arrow、按分子切分的免费并行。这三条都是布局
的直接后果;至于端到端能快多少,要拿基准说话,不写在设计里。
use omgkit_core::{BondOrder, MolBatchBuilder, MolBuilder};
let mut ethanol = MolBuilder::new();
let c0 = ethanol.add_atom(6);
let c1 = ethanol.add_atom(6);
let o = ethanol.add_atom(8);
ethanol.add_bond(c0, c1, BondOrder::Single)?;
ethanol.add_bond(c1, o, BondOrder::Single)?;
let mut bb = MolBatchBuilder::new();
bb.push(ðanol)?;
let batch = bb.finish();
// 列是连续内存
assert_eq!(batch.atomic_nums(), &[6, 6, 8]);
// 单分子是零拷贝视图,下标自动换算为局部
let m = batch.mol(0).unwrap();
assert_eq!(m.degree(1), 2);
两种表示的分工:
| 类型 | 可变性 | 布局 | 用途 |
|---|---|---|---|
MolBuilder |
可变 | AoS,逐分子 | 建图:解析、反应产物构建 |
MolBatch |
不可变 | SoA + CSR,跨分子 | 算法、并行、零拷贝导出 |
经 MolBatchBuilder::push / MolView::to_builder 互转,往返恒等由测试保证。
MolBuilder 自带随建边增量维护的邻接索引(半边链表),neighbors / degree
都是 O(度数),加边仍是 O(1)。索引不是缓存,没有脏标记 —— 为此 bond_mut
不暴露端点,改拓扑只能走建边接口。
一条不可动摇的约定¶
每个原子的邻居顺序 = 键的插入顺序,不按下标排序。这不是实现细节:
SMILES 的 @ / @@ 手性含义依赖邻居在字符串中出现的先后,一旦重排,手性
解释全错。MolBuilder 与 MolBatch 两边都有测试守着这条。
L1:SMILES 解析¶
手写递归下降,不用 lex/yacc。换来的是精确到列的错误位置,以及对下面这些 非显然约定的完全控制。
立体标记记录的是几何类别:四面体的两种排列(@/@@)、平面四方(@SP)、
三角双锥(@TB)、八面体(@OH),类内排列序号单独存一列。把配位几何一律
折成"其它"会丢掉序号,写出时还原不回去,杂化也会判错。配位键(-> / <-)
的端点朝向有语义:begin 是给电子的一端。
use omgkit_io::smiles;
let mol = smiles::parse("CC(=O)O")?;
// 错误带精确位置
let err = smiles::parse("C1CC").unwrap_err();
println!("{}", err.render());
// C1CC
// ^ 环闭合标号 1 未配对
解析器实现的几条非显然约定(环键排序规则、端点朝向、\\ 宽容、手性宇称算法)
记在 crates/omgkit-io/src/smiles.rs 的模块文档里。
L2:净化管线¶
12 个步骤,顺序不可乱:
| 步 | 内容 |
|---|---|
| 1 | 非标准画法修正(硝基 / 叠氮 / 磷酰 / 高卤酸) |
| 2 | 有机金属键改配位键 |
| 3 | 价键计算 + 隐式氢推断 |
| 4 | 环感知 |
| 5 | kekulize |
| 6 | 自由基电子数 |
| 7 | 芳香性感知 |
| 8/9 | 共轭标记 + 杂化标记 |
| 10 | 阻转异构(未实现) |
| 11 | 剔除几何上不成立的立体标记 |
| 12 | 显式/隐式氢调整 |
第 2 步必须排在价键计算之前 —— 它的作用正是让那些超价的原子不再超价,放在 后面的话价键计算已经先一步拒绝了整个分子。
第 10 步(阻转异构)未实现:它在现有语料上改动 0 条分子,没有任何用例守得住 它。没有用例守着的实现比没有实现更危险 —— 它会一直"看起来是对的"。补到能触发 的语料之前不做。
removeHs 不在这 12 步里:删除显式 [H] 会改变原子数,那是一个独立的编辑操作,
由调用方按需显式调用。
环感知只实现纯图论量(非桥边 / 过原子的最短环),不必先求出具体环集;需要
具体环集的芳香性感知另有 sssr 模块,求的是相关环(不能表示为更短环的
GF(2) 和的环)。桥判定与双连通分解都是迭代 Tarjan —— 递归会在长链分子上
爆栈,有 2 万原子的测试守着。
差分测试带一张 KNOWN_DIVERGENCES 登记表,登记已定位根因的分歧。它不是
豁免名单:条目若已不再分歧,测试同样会红并要求删除它 —— 否则豁免会越积越多,
最后没人知道哪些还成立。
L3:SMILES 写出¶
use omgkit_io::smiles;
let mol = smiles::parse("OC(=O)c1ccccc1N")?;
let w = smiles::write(&mol);
assert_eq!(w.smiles, "OC(=O)c1ccccc1N");
// w.atom_order[i] = 输出里第 i 个原子在原分子中的下标
写出与排序刻意分开:write_with_priority 照给定的优先级走 DFS,不自己
挑顺序。两者的判据因此互不干扰 —— 写出看往返恒等(不需要外部参照),
排序看重排不变。揉在一起的话,一个失败就分不清是谁的锅。
往返判据比"字面相等"强:它换算过输出顺序,逐原子比元素/电荷/同位素/显式氢/
映射号/自由基/芳香/方括号/四面体手性,逐键比端点与键级。配位键的端点朝向
单独比 —— 它的 begin 是给电子的一端,不是可以归一的书写痕迹。
四面体手性会写出,并做了与解析器互逆的宇称换算。这一步非做不可:输出会 重排邻居(环闭合键推到末尾、分支顺序变),标记不跟着换参照系就会写出镜像 分子 —— 而原子数、键集合、连通性全都对,纯拓扑比对永远发现不了。
双键方向键也会写出,但只写携带信息的那些:方向键描述的是一根双键两侧
取代基的相对位置,没有双键、只有一侧有方向、或者双键一端挂着两个相同取代基
时,那条 / 都是噪声。噪声方向不只是啰嗦 —— 颜色细化看不见键方向,一条噪声
方向能打破细化分辨不出的对称性,规范 SMILES 就不再随重排恒定。
也正因为写出可能与解析共享同一个误解(互为逆运算却都偏离 SMILES 语义),
只用自家解析器验证是不够的,所以另有一道外部裁判(harness/check_write.py):
两边各自规范化再比字符串。
配位几何(@SP/@TB/@OH)写得出来,而且是钉住的。 裁判
(harness/check_write.py)先前把这一类单独分桶豁免,名单叫 NON_TETRAHEDRAL_GAP;
写出器补上之后那个名单空了,而它是双向钉的 —— 名单里有、实际却写得出来
的条目同样会让判据红,所以它不会悄悄留成一句过期的话。三处调用现在都带 --strict。
它在现有真实语料里出现 0 次,只有冒烟语料造得出来 —— 这正是"压力来自语料的结构, 不是规模"的一个例子。
L3:规范化排序¶
use omgkit_io::{canon, smiles};
let a = smiles::parse("OC(=O)c1ccccc1N")?;
let b = smiles::parse("Nc1ccccc1C(O)=O")?; // 同一个分子,写法不同
assert_eq!(canon::canonical_smiles(&a).smiles,
canon::canonical_smiles(&b).smiles);
判据是随机重排后规范串恒等。重排必须连键序一起打乱 —— 邻居存储顺序 等于建键顺序,只换原子编号的话,一个偷偷读了键序的实现照样能蒙混过关。
三件事:
一、颜色细化(1-WL)。 不能朴素地一轮轮重算 —— 轮数是图直径量级, 11200 原子的长链要跑五千多轮。用分裂器工作表 + "除最大块外入表"(Hopcroft), O((n+m) log n)。
二、立体信息也要参与细化。 纯图细化看不见手性,内消旋型分子因此留下致命 模糊:两个手性中心在图上完全等价,但互换它们的自同构反转手性。办法是把 标记表达成"相对邻居等价类顺序"的宇称 —— 等价类与编号无关,这个宇称也就无关。
三、打破对称要枚举,不能任取。 稳定划分可以粗于自同构轨道,任取一个就把
输入编号偷渡进了结果。语料里有 7 条分子取不同起点会写出不同的串,canon 的
tie_break_matters 可以随时把这个数重新量出来。
枚举的代价靠自同构剪枝压住:两个起点若写出同一个串,两次标号复合起来就是 一个自同构,它轨道里的原子不必再试。800 元大环因此从 199 ms 降到 0.81 ms, 每原子耗时转为持平。
无内容的立体标记只在规范形式里抹掉¶
四个甲基上的 @ 表达不了任何东西 —— 换两个甲基得到的是同一个分子,标记却
翻转了。canonical_smiles 会抹掉这类标记,判准是"有两个邻居同类且那两条
等价支路里没有别的手性中心"。
第二个条件不能省。判准写错的代价是不对称的:判松了只是多一个无意义的 标记,判严了会把两个分子塌成同一个串 —— 只按"两个邻居同类"判,1,4-二取代 环己烷的顺式与反式就会写成同一个串。规范性本身不依赖这一步(唯一性由取最小 值保证),抹掉只是让输出不带无意义的标记。
L4:SMARTS 解析¶
语料抽自真实项目在用的模式集(PAINS 过滤器、官能团层级、RLewis 库),
不手挑 —— 手挑会不自觉地只挑自己已经实现的写法,而真实语料里 =!@、
$(...) 这类正是最容易漏的。判据比"能不能解析"更严:能解析的要逐条规模一致,
该拒绝的也要拒绝。只比可解析性最危险 —— 一个把所有内容都当通配符的实现
能 100% 通过。
use omgkit_io::smarts;
let q = smarts::parse("[C,N;H1]!@[O,S]")?;
// q.topology 只有拓扑;q.atoms[i] / q.bonds[i] 是查询树
查询是外挂的:QueryMol = 只承载拓扑的 MolBuilder + 逐原子逐键的查询树。
分子的数据结构因此不必为查询付任何代价 —— 列式存储里不会多出一列指针,
也不会有"这个字段在查询分子里含义不同"的暗坑。
三处凭直觉会写错的地方¶
[H] 不是规则,是一张特例表。 从观测反推不出规则 —— [H1] 和 [H+]
都是 H 开头,解读却相反。它是枚举出来的完整括号形式清单(可选同位素 + H +
可选电荷 + 可选映射号 + ]),落在表里才是氢元素,否则一律是氢计数。
二字符元素符号先于单字符基元匹配,且严格区分大小写。 反例和正例一样重要:
as → 砷 |
aS → a & S |
Ac → 锕 |
AC → A & C |
Hg → 汞 |
Va → V & a |
Rb → 铷 |
Xx → X1 & x |
只测正面的话,"见到两个字母就当元素"也能全绿。
键表达式允许并置,并置就是与。 键符号都是单字符,乍看并置会分不清"一个
复合表达式"还是"两条键",其实不歧义:键表达式夹在两个原子之间,后面没有放
第二条键的位置。=!@(双键且非环键)在真实语料里很常见,776 条里有 106 条
用到键的逻辑运算。
L5:子结构匹配¶
VF2++。定序按"与已放置原子的连边数 → 候选数 → 度数",候选数由原子表达式
推出的元素集合估算 —— 一条 CCCCCCCCBr 从溴那端起头候选只有一个,从碳起头
是几百个。估算只能放宽不能收紧:推不出约束时返回"什么都可能",估紧了会让
匹配器漏候选,那不是慢,是错。
use omgkit_match::{substructure_matches, MatchOptions, MolProps};
let props = MolProps::compute(&mol);
let hits = substructure_matches(&query, &mol, &props, MatchOptions::default());
手性与顺反是映射的性质¶
手性标记相对各自分子的邻居存储顺序。查询与底物的存储顺序不同,直接比原始 标记就是拿两个参照系里的值去比,结论可以正好相反。所以两者都在映射齐了 之后才校验:把查询邻居映到底物,与底物的存储序算置换宇称,再决定标记要不 要翻转。底物比查询多一个邻居时(度 3 的查询配度 4 的底物),那一个的位置也 会改变宇称,必须补进置换里。
MatchOptions::use_chirality 决定这套校验开不开。摆成显式字段是因为影响面大
—— 2000 分子 × 776 模式里 23% 的组合开与不开结果不同。子结构匹配默认开
(作者写了 [C@] 却被悄悄忽略,是最难发现的一类错);run_reactants 默认
关(反应模板跨工具流通,读得更严会让现成模板不再出产物,而"少了产物"
比"多了产物"更难发现)。
L7:反应¶
use omgkit_match::{run_reactants, MolProps};
let rxn = smarts::parse_reaction("[C:1](=[O:2])[OH].[N:3]>>[C:1](=[O:2])[N:3]")?;
// 末位参数是"要不要生成原子映射号"
let outs = run_reactants(&rxn, &[(acid, acid_props), (amine, amine_props)], 100, false);
for o in &outs {
// o.products:每个产物模板一个分子
}
递入顺序不是化学¶
run_reactants 的契约是"每个反应物模板配一个互不相同的输入分子"——注意
不是"第 i 个配第 i 个"。位置是调用方敲键盘的顺序,不该决定这条反应跑不跑得起来。
实现上仍然先试恒等分配(第 i 个配第 i 个):顺序本来就对得上时,开销与只试这一种 完全相同。恒等分配一个产物都给不出,才去找别的一一对应,按字典序取第一个能出 产物的。回退那条路最多多算 n²−n 次子结构搜索(n 是反应物模板数,现实中 1–3), 而且只在本来就要返回空的时候才走。
这么定的理由不是对称美,是空列表原先有两个意思:
这批分子上没有反应位点 ← 调用方要的结论
你递入的顺序与模板片段对不上 ← 调用方看不出来,而且改一下就好了
两者长得一模一样。USPTO-50k 正向语料按记录自带的分子顺序直接调用,689 条 交白卷;抽样 4000 条逐条核过,其中 59 条全部属于第二种,一条都不是第一种。 补上回退之后,正逆双向全语料 0 条交白卷。
A/B 实测(同一份语料、同一台机器):中位 15.92 → 15.83 µs,p99 349.8 → 344.8 µs, 五万条合计 1.532 → 1.519 s —— 都在噪声里,快路一分钱没多花。
"多个片段落在同一个分子上"是另一件事,不归这里管:那是分子内反应的形状,
一一对应表达不了,由 run_on_substrate 承担(见上文"片数由底物决定")。
原子映射号是可选产出¶
模板里的 [C:1] 只在模板内部成立 —— 它连的是"反应物模板的这个原子"与
"产物模板的那个原子",与底物无关。底物上真正的原子对应关系是运行时才产生的:
一部分由模板匹配定下,其余来自模板之外原样搬运的那些原子。
atom_mapping 参数开启后,这份运行时对应关系被固化成映射号,反应物副本与
产物两侧都带号,写出来就是一条完整的原子映射反应:
[C:1](=[O:2])[OH].[N;H2:3]>>[C:1](=[O:2])[N:3] 作用在 CC(=O)O + NCc1ccccc1
[CH3:1][C:2](=[O:3])O.[NH2:4][CH2:5][c:6]1[cH:7][cH:8][cH:9][cH:10][cH:11]1
>> [C:2](=[O:3])([N:4][CH2:5][c:6]1[cH:7][cH:8][cH:9][cH:10][cH:11]1)[CH3:1]
注意羧酸那个离去的 OH 氧没有号:它在产物侧没有对应者。只给两侧都在的
原子发号是有意的 —— 一个在另一侧找不到的号,读的人只能理解成"这个原子凭空
消失",而那正是号要表达的反面。号也不沿用模板里的:模板只覆盖分子的一小块,
搬运过来的部分本来就没有号可沿用,两套号混在一起会撞。
产物构建里最需要小心的是参照系:映射号把反应物原子搬到产物侧时,邻居 顺序会变,手性标记必须跟着重定基;取代基被替换时,新原子要占据被替换者 原来的槽位而不是追加到末尾,否则中心的构型会翻;双键的方向键要按产物侧的 参照原子重新生成,而不是照抄继承来的那条。这几处错了都不报错,只是产物变成 了另一个异构体。
产物生成有外部裁判(harness/check_reactions.py):两边的产物都交给同一个
读者规范化再比多重集。
与外部参照剩下的差别来自一个刻意的设计选择:本库在 L1/L2 保留显式 [H]
原子(removeHs 划在净化之外),而参照实现在解析时就把它合并掉了 —— 同一个
底物在两侧的图本来就不同,产物自然也不同。这一类不算分歧,但要单独分桶,
不能混进"零分歧"里蒙混过去。
被丢弃的原子:先记事实,再谈推断¶
反应记录普遍只写主产物,酸与醇成酯只记酯,水没了。模板是从记录抽的,于是模板 也只描述主产物,引擎照模板办事,那部分原子就从输出里消失。消失不报错, 而它恰恰是引擎自己在破坏质量守恒。
这件事拆成两层,分开是有意的:
| 是什么 | 可信度 | |
|---|---|---|
Outcome::discarded |
哪些底物原子没进产物 | 事实,永远成立 |
byproduct::reconstruct |
那些原子收口成了什么分子 | 推断,自带档次 |
模板里没有"离去基团变成了什么"这条信息,但账是可以算的:空价(被切断的键 的键级和)、片段自带的氢、氢预算(底物总氢 − 产物总氢)、以及电荷预算。补一个氢 填掉一处空价;预算为负则要摘氢,每摘一个反而多出一处空价,所以两种情形是同一个 式子。剩余空价必须两两成键消化掉 —— 配不成对就是这条记录本身给不出答案, 多半是记录漏写了贡献原子的试剂,那时引擎明说"答不了",不编分子。
填空价的不只有氢,还有形式电荷。 溴从 C–Br 上断下来时带走那对电子,成的是 Br⁻ —— 一处空价被一个负电荷填掉,一个氢都不需要。只按氢记账的话这一档永远配不成 对,会被误判成"记录不平"。这一处是归因分析在真实语料上翻出来的,不是想出来的: 季铵化、亲核取代里离去基团本来就以阴离子离去。反过来正电荷多出一处价(铵氮 有四根键),所以电荷带符号进账。
delta_h 就是副产物应有的氢数,负数在物理上讲不通,直接硬拒并单独成档 ——
它指向的东西很具体(记录少写了供氢试剂,硝基还原是典型),混进"配不成对"里就
只剩一句没信息的话。
收口另有三处讲究,都是"报得准"而不是"报得多":
- 重原子账要在
reconstruct里查,不能只在基准脚本里查。 收口只该补氢、落 电荷、成键,一个重原子都不该增减;而氢账与电荷账盯不住它 —— 凭空多一个 重原子的同时,那两笔完全可以照样配平。reconstruct是公开 API,调用方拿到的 东西没人替他查。变异实测:往收口结果里塞一个碳,13 条判据同时变红。 - "配不成对"与"键太多"要分开报。 前者是物理上配不起来(只剩一个位点,或 只剩两个卤素),后者是本实现主动不找了。混成一个出口,归因就会把前者全算成 "搜索爆掉" —— 全量实测有 2102 个 outcome 落在这个误标上。
- 配位键的给体端不占价。 电子对是它自己出的,断开之后不欠任何东西;按对称 键级算会凭空记一处空价,把一条完整的记录判成收不平。
- 电荷对空价的作用由元素定,不是"负减正加"。 价由有效原子序数定,所以同样
加一个负电荷,氧少一处价而硼多一处;同样加一个正电荷,氮多一处而碳少
一处。写死符号的话,碳正离子与硼酸根这两类会算反。真相来源是
omgkit_chem::valence_shift,与隐式氢推断共用同一张价表。也因此,电荷必须 先落定再算剩余空价 —— 落在哪个元素上决定了它是填掉一处还是多出一处, 落定前估不准。
后两条与手性重定基一样,语料立不住、靠构造判据立:全量对拍各改动 0 处。 判据都盖住两个方向 —— 只测"负电荷填掉一处"的话,写死符号的实现照样全绿。
收口的位点表是每个片段原子一条,不是每处空价一条 —— 所以"遍历位点"的代价
正比于整个片段,而真正欠着价的通常只有两三个原子。把这样一次遍历放进按位点或
按键的循环里就是平方项。三处都改成下标索引(Site 因此不再存 idx:位点与
原子严格一一对应,下标本身就是对应关系,存了反而多一处可能对不上的地方),配对
只在还欠着价的位点之间做。守卫在 omgkit-match/tests/scaling.rs,阈值拿真实缺陷
标定:线性时涨 1.13 倍,塞回一个平方项时 3.92 倍。
标定时踩过一个坑:第一次塞回去曲线一点没动 —— 塞的那个形状是循环不变量,被 编译器提到内层循环外了。用"塞回去看它红不红"标定阈值时,得先确认塞的东西 真的在跑,否则标出来的阈值毫无意义。
判据只有一条,而且不依赖任何记录:闭合的结果,产物 + 副产物的重原子数必须 精确等于输入。全量 50016 条正向、224356 个 outcome 上违例 0;闭合 94.1% (补氢 47.0% / 另需成键 45.3% / 本来就没丢 1.8%),未决 5.9%。
给的是形式副产物,不是分离得到的那个。 多数时候两者相同(水、氯化氢、醇), 但 Boc 脱保护的形式副产物是叔丁基碳酸,实际拿到的是二氧化碳加异丁烯。分解要靠 一张规则表,那是另一件事 —— 形式副产物有硬判据,分解规则没有,混在一个输出里 就再也分不清哪个是证出来的、哪个是猜的。
摘氢那一步有个只在真实语料上才暴露的坑:先前按"非氢非卤就当它出得起氢"估, 于是醚氧、酯羰基碳这类一个氢都没有的原子也会被选中,成键之后当场超价。冒烟用例 一条都没碰到,全量上却占了未决档的大头(10.1%),改成看从底物带过来的氢之后 降到 0.006%。
片段从凯库勒式的副本上切,不从芳香式的原分子上切。断口的键级是底物的性质:
苯环上一个碳的两根环键按凯库勒式是一单一双、合计 3,按"芳香键算 1"数成 2 就把
奇偶翻了;而芳香标志一旦搬进不再成环的片段,净化会报"原子不在环中却带着芳香
标志"——那个理由是错的,真正的原因在切的那一步。kekulize 一次调用两件事都办了。
收口另有一道几何守卫:三键不许塞进小于八元的环。这一档躲得过前面每一道 —— 原子账平、电荷账平、净化也过得去,因为价规则管的是"几根键",管不到"这几根键 摆得下摆不下"。不拦的话交出去的是一个配平、合法、下游看不出问题的错分子。 两处必须一起做:修了 kekulize 会把这条路打开,只修前者等于把一条诚实的失败换成 一个错答案。全量对拍实测,两处合起来在 52261 个 outcome 里只动 3 个 —— 2 个由 "净化不过"变成正确的咪唑,1 个由"账目错"变成如实的"几何上不可能"。
切点落在手性中心上时,标记要换参照系。被切邻居从邻居表里消失、顶上来的隐式氢
按本库约定占下标 1;原邻居在下标 0 或 2 时置换为奇、必须翻,在 1 或 3 时是偶的、
不该翻。一律不翻的话,同一个分子的八种等价写法里错四条 —— 而原子数、键、
连通性、原子账全都对,纯拓扑比对与质量守恒判据都发现不了。换参照系复用产物侧
那一套(align_for_rebase / permutation_is_odd),不另写:这件事只该有一个
真相来源。
这一处语料立不住,靠构造判据立:两万条里副产物带手性标记的只有 7 条,而它们 的切点全部落在氧上,这条路一次都走不到;全量对拍实测改动 0 处。期望值由外部 实现独立算出,判据同时盖住两个方向 —— 只测"该翻"的话,一个"一律翻转"的实现 照样全绿。
判据是 a_fragment_whose_neighbours_carry_no_hydrogen(Cbz 形状:断点的两个邻居
都不带氢)。不是 an_ethyl_ester_leaves_as_ethylene —— 乙基上相邻原子恰好都
带氢,新旧写法给同一个答案,那条判据碰不到分歧点。这一处订正本身就是个例子:
判据的文档说它守什么,和它真的守什么,是两件要分别核验的事。
净化之外:图特征描述符¶
图神经网络读分子,读的是一组固定的原子/键描述符。omgkit 交出的是
原子 12 项、键 7 项,由 omgkit_chem::{atom_descriptors, bond_descriptors}
汇到一处;Python 侧是 Mol.atom_descriptors() / Mol.bond_descriptors()。
它们不是净化的步骤,是净化输出的消费者 —— 十九个键里十三个直接读 L2 填好的 字段,新算的只有 Gasteiger 部分电荷,新查的只有 Pauling 电负性和同位素精确质量。
为什么要有这一层,而不是让调用方各拼各的¶
值全都散在别处:元素表里有原子量,AtomData 上有形式电荷与杂化,标志位里有
芳香与环成员。散着的问题不是难找,是每个调用方都要自己拼一遍,而"总连接度
含不含隐式氢""总氢数算不算图里那些独立的 [H] 原子"这类口径,拼十遍会有十个
说法。分歧还是静默的:模型照训,只是特征列和别人的对不上。
所以口径只写一处。三条最容易各拼各的,按外部实现钉死:
| 口径 | 定法 |
|---|---|
| 总连接度 | 显式邻居数 + 总氢数,不是度数 |
| 总氢数 | 显式声明 + 隐式推断,不含图里独立的 [H] 原子 |
| 原子量 | 标了同位素就用那个核素的精确质量,没标才用标准原子量 |
第三条是被判据逼出来的:氘的标准原子量是 1.008、精确质量是 2.0141,差了一倍, 而语料里真有氘代分子。为它引进了一张 3111 条的同位素质量表。
交的是描述符,不是编码¶
分类量给的是名字("sp3"、"ccw"、"aromatic"),不是 one-hot,也不是
整数编号。
词表该收哪几种元素、留不留"其它"兜底桶、三个连续量怎么缩放 —— 这些是特征化 那一侧的决定。写进库里,等于把某一个模型的口径当成库的口径,下一个模型就得 绕过它。整数编号更糟:它是沉默的 —— 枚举中间插一个变体,全部编号后移一位, 拿它建过特征列的人不会收到任何提示。名字改了会当场炸在调用方脸上。
两种"算不出",都不许拿默认值顶¶
- Pauling 电负性:该元素没有公认值(稀有气体、Pm/Eu/Tb/Yb/Fr)时是
None。 - Gasteiger 电荷:参数表覆盖 H/C/N/O/F/Si/P/S/Cl/Br/I/B/Be/Mg/Al,表外元素
(多数金属)的电离能标度是 0,流量的分母为 0,结果非有限并且沿图扩散 ——
连着钠的那个碳也跟着失效。所以另出一位
gasteiger_valid。
两处都如实交出缺失。理由不是洁癖:特征化那一侧要决定这一维该不该屏蔽,而它
只有在"不知道"和"值恰好是这个数"还分得开的时候才决定得了。补一个看着合理的
默认值,恰恰是在唯一在意这个区别的地方把它们合并掉。这和出图那边报 degraded
而不是硬交一张读不出构型的图,是同一条。
Gasteiger 的实现照抄到什么程度¶
参数表转录自外部实现的两段常量,迭代公式照抄,连 (a−b)+b 都没有化简成
a —— 浮点里两者不相等,化简会让结果与参照差在末几位,而判据比的正是同一
批数。表外元素的 NaN 也照原样传出去,不做兜底:兜底会让"算不出"和"恰好是 0"
变成同一件事。
隐式氢没有各自的节点,但参与迭代:一个重原子带的 n 个氢彼此不相邻、只连着 同一个重原子,所以可以当作一个整体在同一轮里更新。这不是近似,是把 n 个完全 等价的节点合并同类项。
顺反给的是 cis/trans,不是 Z/E¶
Z/E 按 CIP 优先级定义,而 CIP 排序本仓库没有实现。描述符给的是"相对
记录下来的那两个参照原子"的顺反,并且把参照原子一并交出(stereo_atoms)。
两项必须一起看:顺反离开参照没有意义 —— 四取代双键上参照挑得不同,同一个几何
会得出相反的标签。带上参照之后,两者承载的几何信息相同,要 Z/E 的调用方
自己排一次 CIP 就能换算。
判据¶
harness/check_descriptors.py,走产品那条路,五份语料 9088 个分子逐原子逐键与
外部实现比,分歧 0;七条刻意分歧逐条钉死且双向(少命中一条也红),根因全在
参照那一侧。四份语料各攻一档,少喂一份对应那几列就恒为同一个值。
够不着的那一档写在判据文件头:电负性的值它看不见(外部实现没有公开
接口能读那张表),那一项由 omgkit-core 的单元测试守。
L8:Python 绑定¶
PyO3 + maturin,abi3 编译 —— 一个 wheel 覆盖 Python 3.9 及以上,不必逐个 小版本各编一份,也不带任何系统依赖。
import omgkit
m = omgkit.parse_smiles("OC(=O)c1ccccc1N")
m.sanitize()
m.to_canonical_smiles() # 'c1cccc(c1C(O)=O)N'
q = omgkit.parse_smarts("[C](=[O])[OH]")
q.match(m) # [[1, 2, 0]] —— 按查询原子顺序给出分子原子下标
rxn = omgkit.parse_reaction("[C:1][OH:2]>>[C:1][Cl:2]")
for o in rxn.run([m], atom_mapping=True):
o.products, o.reactants # 两侧都带原子映射号
绑定层只做翻译:把参数翻过去、把结果翻回来。一旦在那里写了判断分子的逻辑, 它就只有 Python 用户碰得到,Rust 侧的整套差分测试一概盖不到。
三处翻译层特有的坑,各有测试守着(harness/test_python.py):
| 坑 | 症状 |
|---|---|
Vec<u8> 被特判成 bytes |
mol.atomic_nums 返回 b'\x06\x08',索引出来仍是 int,类型却错了 |
panic = "abort" |
任何一处 Rust panic 直接 SIGABRT 掉解释器,try/except 拦不住 |
| 错误翻成一句笼统的话 | 解析错误的插字号视图丢掉,排查线索没了 |
第二条是工作区级的取舍:cargo 不允许按 package 覆盖 panic,所以整个 release
profile 都必须用展开。实测那点常数开销在管线基准上看不出来。
复杂度是一等公民¶
整条管线必须线性于分子规模。差分测试抓不到这类问题 —— 结果全对,只是慢,
而且在小分子上完全看不出来。crates/omgkit-chem/tests/scaling.rs 专门盯增长
曲线,判据是每原子耗时不随规模上升,覆盖三种压力形状(很多个小环系 / 单个
大稠合体系 / 单个大环)。规范化另有一套(omgkit-io/tests/canon_scaling.rs),
压的是细化轮数、打破对称的分支数与方向键判定。阈值全部拿真实缺陷标定过 ——
关掉自同构剪枝,大环那档立刻报"涨了 2.04 倍"。
依赖策略¶
omgkit-core 刻意保持零依赖。核心 crate 的依赖会传染给整个工作区,
而这一层需要的东西(定长数组、位运算、CSR)标准库都有。
元素周期表由 harness/gen_elements.py 生成,不手写 —— 默认价表直接决定
隐式氢推断,手抄错一个数字会在全量差分测试里表现为几千条难以定位的分歧。
那张表现在有四块:119 个元素的基本数据、3111 条同位素精确质量、93 个元素的 Pauling 电负性、以及 kekulize 用的"早期元素"表。三块非基本数据是描述符要的 (原子量在标了同位素时要给那个核素的精确质量),各来自外部实现源码里的不同区段。
生成的理由在这里又验了一次:同位素那一段被拆成六十多个原始字符串块,其中两块 的首行数据与开括号写在同一物理行上,不抹掉括号就会静默丢掉 H-1 与 Ca-47 —— 丢了不报错,只是氘查得到、氕查不到。这类错误手抄同样会犯,而且更难发现。所以 生成器里加了一条跨表自查:每个有同位素数据的元素必须含它自己的"最常见同位素" (那个质量数写在元素表那一行里,不在同位素表里),分块解析漏掉整块首行时它会响。
三维图的 CPK 配色表同样是生成的(harness/gen_palette.py →
crates/omgkit-depict/src/palette_data.rs),理由与元素表一样:109 个元素乘三个
字节,手改错一个不会有任何报错,只会让某种元素在图上穿别人的衣服 —— 而"图上
颜色不对"没人会去查表。闸门重跑一遍生成器再逐字节比。
那张表不放进元素表:颜色是绘图惯例,不是物性。同一个碳在 Jmol 是 #909090、
在 Rasmol 是 #c8c8c8,而范德华半径两边都是 1.7 Å;混在一张表里,下一个人就会
以为颜色也有个"正确值"。
omgkit-depict 依赖 omgkit-conf,方向是别扭的¶
三维图的规范视角要主轴,主轴要对称矩阵的特征分解,而本仓已经有一处
(omgkit_conf::linalg,循环 Jacobi,判官是 LAPACK)。为 3×3 再写一个专用版
就是同一个概念的第二处实现 —— 两处迟早在某个病态输入上分岔,而"视角差一点"
没有任何判据看得见。
代价是这条箭头读起来别扭(绘图依赖构象生成)。它不成环:omgkit-conf 只依赖
core / chem / io,不认识 depict。发布到 crates.io 时 conf 要排在 depict 之前。