Skip to content

设计(全文)

逐层的设计取舍与验证方式。这是仓库里的工程记录原文,中文。

源文件: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(&ethanol)?;
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 的 @ / @@ 手性含义依赖邻居在字符串中出现的先后,一旦重排,手性 解释全错。MolBuilderMolBatch 两边都有测试守着这条。

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 条分子取不同起点会写出不同的串,canontie_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 → 砷 aSa & S Ac → 锕 ACA & C
Hg → 汞 VaV & a Rb → 铷 XxX1 & 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/ECIP 优先级定义,而 CIP 排序本仓库没有实现。描述符给的是"相对 记录下来的那两个参照原子"的顺反,并且把参照原子一并交出(stereo_atoms)。

两项必须一起看:顺反离开参照没有意义 —— 四取代双键上参照挑得不同,同一个几何 会得出相反的标签。带上参照之后,两者承载的几何信息相同,要 Z/E 的调用方 自己排一次 CIP 就能换算。

判据

harness/check_descriptors.py,走产品那条路,五份语料 9088 个分子逐原子逐键与 外部实现比,分歧 0;七条刻意分歧逐条钉死且双向(少命中一条也红),根因全在 参照那一侧。四份语料各攻一档,少喂一份对应那几列就恒为同一个值。

够不着的那一档写在判据文件头:电负性的它看不见(外部实现没有公开 接口能读那张表),那一项由 omgkit-core 的单元测试守。

L8:Python 绑定

PyO3 + maturin,abi3 编译 —— 一个 wheel 覆盖 Python 3.9 及以上,不必逐个 小版本各编一份,也不带任何系统依赖。

maturin build --release -m crates/omgkit-py/Cargo.toml --out dist
pip install dist/omgkit-*.whl
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 倍"。

cargo run --release --example bench_pipeline -- <语料> permol

依赖策略

omgkit-core 刻意保持零依赖。核心 crate 的依赖会传染给整个工作区, 而这一层需要的东西(定长数组、位运算、CSR)标准库都有。

元素周期表由 harness/gen_elements.py 生成,不手写 —— 默认价表直接决定 隐式氢推断,手抄错一个数字会在全量差分测试里表现为几千条难以定位的分歧。

那张表现在有四块:119 个元素的基本数据、3111 条同位素精确质量、93 个元素的 Pauling 电负性、以及 kekulize 用的"早期元素"表。三块非基本数据是描述符要的 (原子量在标了同位素时要给那个核素的精确质量),各来自外部实现源码里的不同区段。

生成的理由在这里又验了一次:同位素那一段被拆成六十多个原始字符串块,其中两块 的首行数据与开括号写在同一物理行上,不抹掉括号就会静默丢掉 H-1 与 Ca-47 —— 丢了不报错,只是氘查得到、氕查不到。这类错误手抄同样会犯,而且更难发现。所以 生成器里加了一条跨表自查:每个有同位素数据的元素必须含它自己的"最常见同位素" (那个质量数写在元素表那一行里,不在同位素表里),分块解析漏掉整块首行时它会响。

三维图的 CPK 配色表同样是生成的(harness/gen_palette.pycrates/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 之前。