Skip to main content

omgkit_match/
react.rs

1//! 按反应模板生成产物。
2//!
3//! # 映射号定义了一切
4//!
5//! 反应物模板与产物模板之间**只有映射号**这一条纽带:
6//!
7//! | 原子在哪 | 有映射号 | 无映射号 |
8//! |---|---|---|
9//! | 只在反应物模板 | 不可能(号在两侧都要找得到才叫"有") | **删掉**匹配到的那个原子 |
10//! | 两侧都有 | **保留**匹配到的原子,按产物模板改写属性 |  |
11//! | 只在产物模板 | 同上,视作新建 | **新建**一个原子 |
12//!
13//! 键同理:产物模板里有的键就建,反应物模板里有而产物模板里没有的键就断。
14//!
15//! # 产物分子数由连通性决定
16//!
17//! 产物模板描述的是反应中心的片段,**不是**一个片段一个分子。所有产物模板建进
18//! 同一张图,模板之外的原子只搬一次,最后按连通分量切开。
19//!
20//! 逐产物各建一张图会出大问题:两个片段的锚点若仍通过未匹配的原子相连
21//! (分子内反应、断环键),那批原子会被复制进每一个产物,**质量当场不守恒**
22//! 而没有任何东西报错。
23//!
24//! # 模板之外的部分原样带过来
25//!
26//! 反应物分子里没被模板匹配到的原子和键要**照原样搬进产物**。这一条决定了
27//! 反应模板可以写得很小 —— `[C:1][OH:2]>>[C:1][Cl:2]` 只描述羟基变氯,
28//! 分子其余部分自动跟着走。
29//!
30//! # 产物不做净化
31//!
32//! 产物是"按模板改写出来的图",可能价键不合法(模板本身就能写出不合法的东西)。
33//! 净化与否交给调用方 —— 在这里净化会把"模板写错了"和"这条反应本来就不该
34//! 用在这个底物上"混成同一种失败。
35//!
36//! # 原子映射号是可选产出
37//!
38//! 模板里的映射号只在**模板内部**成立,它连的是"反应物模板的这个原子"与
39//! "产物模板的那个原子",与底物无关。底物上真正的原子对应关系是运行时才
40//! 产生的:模板匹配定下一部分,模板之外原样搬运的部分定下其余的。
41//!
42//! [`run_reactants`] 的 `atom_mapping` 参数控制要不要把这份运行时对应关系
43//! 固化成映射号。开启时返回的 [`Outcome`] 里反应物与产物**两侧都带号**,
44//! 同一个号出现在两侧就表示是同一个原子。
45
46use std::collections::{BTreeMap, BTreeSet};
47
48use omgkit_core::{
49    AtomData, BondData, BondDirection, BondOrder, BondStereo, ChiralTag, MolBuilder,
50};
51use omgkit_io::smarts::{
52    map_number, required_chirality, AtomExpr, AtomPrim, BondExpr, BondPrim, QueryMol, Reaction,
53};
54
55use crate::matcher::{substructure_matches, MatchOptions};
56use crate::props::MolProps;
57
58/// 一次反应的产物。
59///
60/// **长度不等于产物模板数。** 产物模板描述的是反应中心的片段;这些片段在底物里
61/// 断没断开,要看模板之外的原子还连不连着。分子内环化的逆向就是这样:模板写成
62/// 两个片段,可打开一个环并不产生两个分子。
63pub type ProductSet = Vec<MolBuilder>;
64
65/// 一个匹配组合跑出来的结果。
66#[derive(Debug, Clone)]
67pub struct Outcome {
68    /// 产物。数目由**连通性**决定,不由产物模板数决定,见 [`ProductSet`]。
69    pub products: ProductSet,
70    /// 带映射号的反应物副本。
71    ///
72    /// **只在开启 `atom_mapping` 时非空。** 关掉时反应物一个字节都不会被改动,
73    /// 调用方手上的输入就是答案,复制一份纯属浪费 —— 而一次反应可以产出上百组
74    /// 结果,每组都复制一遍反应物不是小开销。
75    pub reactants: Vec<MolBuilder>,
76    /// `discarded[i]` = 第 i 个输入分子里**没有进入任何产物**的原子下标,升序。
77    ///
78    /// 这是一条**事实记录,不含任何推断**:模板明说要删的原子、以及只挂在它们
79    /// 身上因而失去落脚点的原子,都在这里。产物侧看不到它们,所以不记的话这批
80    /// 原子就是凭空消失 —— 而"消失"与"被判定为不该存在"是两回事。
81    ///
82    /// 把它们收口成分子是**另一件事**,由 [`crate::byproduct::reconstruct`] 做,
83    /// 而且是推断:模板里没有"离去基团变成了什么"这条信息。两者分开是有意的 ——
84    /// 本字段永远可信,那边的结论要看它自己给出的档次。
85    pub discarded: Vec<Vec<u32>>,
86}
87
88/// 对一组反应物跑反应,返回所有结果组。
89///
90/// 每个反应物模板配一个**互不相同**的输入分子;分子数与模板数不等时返回空
91/// (那一档是 [`run_on_substrate`] 的形状:多个片段落在同一个分子上)。
92///
93/// 每个匹配组合产出一组结果 —— 底物上有几处能反应就有几组,内容可能重复
94/// (对称位点)。去重是调用方的事:要按什么去重取决于用途,规范 SMILES
95/// 多重集只是其中一种。
96///
97/// # 递入顺序不影响出不出产物
98///
99/// **位置不是化学。** 谁先谁后是调用方敲键盘的顺序,不是分子的性质,所以
100/// 它不该决定这条反应跑不跑得起来。
101///
102/// 实现上仍然**先试恒等分配**(第 i 个模板配第 i 个分子)—— 顺序本来就对得上
103/// 时开销与只试这一种完全相同;恒等分配一个产物都给不出,才去找别的一一对应,
104/// 按字典序取第一个能出产物的。所以:
105///
106/// - 顺序对得上:行为与耗时都不变
107/// - 顺序不对:照样出产物,而不是交白卷
108/// - **返回空只剩一个意思:这批分子上没有反应位点**
109///
110/// 这一条是量出来的,不是想出来的。USPTO-50k 正向语料里,按记录自带的分子
111/// 顺序直接调用,约 **689 条**交白卷;抽样 4000 条逐条核过,其中 **59 条全部**
112/// 只是顺序对不上 —— 换个顺序就出产物,**没有一条**是真的匹配不上。
113/// 而调用方拿到的是同一个空列表,分不出这两件事。
114///
115/// 回退那条路要多算最多 n²−n 次子结构搜索(n 是反应物模板数,现实中 1–3),
116/// 而且**只在本来就要返回空的时候才走** —— 拿它换的是一个静默的错答案。
117///
118/// 开了 `atom_mapping` 时,哪个分子担了哪个角色可以从映射号读回来:
119/// [`Outcome::reactants`] 里的副本按**输入顺序**排,号是按底物原子发的。
120///
121/// # 哪些原子会进产物:只有从保留下来的原子**走得到**的
122///
123/// 产物 = 模板产物侧建出来的原子,加上从它们出发在底物里能走到的部分。走不到
124/// 的原子不进产物,**不报错**。这条规则有两个看得见的后果,都是刻意的:
125///
126/// **一、模板删掉一个原子时,只挂在它身上的东西跟着走。** 叔丁酯水解的模板写
127/// `C-C-[O:1]-[C:2]=[O:3]`,删掉的 `C-C` 是叔丁基的一个甲基加季碳;季碳上另外
128/// 两个甲基没有别的路连回保留部分,于是一并消失 —— 一次少掉 4 个重原子而不是 2 个。
129/// 真实反应语料里这一档数以千计,不是边角情形。
130///
131/// **二、完全不连通的旁观组分原样交回来,不丢。** 底物写成 `内酰胺.HCl` 或
132/// `[Na+].[O-]CC(=O)OCC[O-].[K+]` 时,反离子与任何匹配到的原子都不连通,遍历
133/// 走不到它们。**但走不到不等于该丢** —— 丢了产物的重原子数就少于底物,是引擎
134/// 自己在破坏质量守恒,而且不报错。逆合成正是把模板作用到任意分子上,盐是常态。
135///
136/// 所以这些组分会被原样搬进产物图,按连通分量切开之后各自成为一个产物分子。
137/// **引擎不替调用方决定归属** —— "这个反离子该跟哪一半走"没有普遍答案,模板里
138/// 也没有这条信息;交回去,由调用方定。
139///
140/// 与上一条的分界是"这个组分里**有没有**原子被模板匹配到":有,留下还是删掉
141/// 是模板的表态;没有,模板压根没提到它。
142///
143/// # `atom_mapping`
144///
145/// 开启后,[`Outcome::reactants`] 填上带映射号的反应物副本,产物侧的对应原子
146/// 打上同一个号 —— 两侧合起来就是一条完整的原子映射反应。关闭时两侧都不带号,
147/// `reactants` 留空。
148///
149/// 发号的规则:
150///
151/// - **只给两侧都在的原子发。** 被反应删掉的、产物侧新建的都不发 —— 一个在
152///   另一侧找不到的号,读的人只能理解成"这个原子凭空消失/出现",而那正是号
153///   要表达的反面。
154/// - **号是新发的,不沿用模板里的。** 模板的 `[C:1]` 连的是两个模板而非底物,
155///   而且模板只覆盖分子的一小块,搬运过来的部分本来就没有号可沿用。反应物
156///   副本上原有的号会先清掉,免得留下在产物侧找不到的悬空号。
157/// - **顺序**:按 `(第几个反应物, 原子下标)` 升序,从 1 连续发。同一份输入
158///   永远得到同一套号;换一种原子编号写同一个分子,号会跟着变 —— 映射号本就
159///   是贴在某一种画法上的标签,不是分子的不变量。
160/// - 一个号在同一侧只出现一次。产物是按连通分量切出来的,每个底物原子只进
161///   一个产物,所以这一条自然成立。
162///
163/// ## 写出带号的产物之前要先净化
164///
165/// 与"产物不做净化"那一条配套:**隐式氢数是派生量,图改完就过期了**。模板删掉
166/// 一个邻居之后,那个原子该补几个氢要重算,而重算在净化里。
167///
168/// 平时看不出来 —— 裸写形式(`N`、`C`)把氢数交给读的人按价规则去推,推出来
169/// 的正是重算后的值。可**带映射号的原子必须写进方括号**,而方括号里的氢数是
170/// 显式的,写出去的就是那个过期的缓存值。于是同一个产物,开不开映射号会写出
171/// **氢数不同**的两串。
172///
173/// 所以带号写出之前先 [`sanitize`](omgkit_chem::sanitize)。实测语料上:不净化
174/// 直接写,31 万个 outcome 里有 176 个两串对不上;先净化再写,**0 个**。
175///
176/// # 调用前要先感知双键顺反
177///
178/// 反应物应当先跑
179/// [`perceive_bond_stereo`](omgkit_io::stereo::perceive_bond_stereo) ——
180/// 净化那 12 步里**没有**它(感知要用对称等价类,那在净化的上一层,调不到)。
181///
182/// 漏了这一步不会报错,会**静默丢几何**:方向键(`/` `\`)依附在某根单键上,
183/// 反应把那根键删掉,几何就跟着没了 —— 哪怕双键本身根本没被碰过。产物照样
184/// 合法、原子数照样对,只有顺反悄悄少了。感知一次之后信息记在双键自己身上,
185/// 只要参照原子还在就活得下来。
186///
187/// ```no_run
188/// # use omgkit_core::MolBuilder;
189/// # use omgkit_match::{run_reactants, MolProps};
190/// # fn demo(mut mol: MolBuilder, rxn: &omgkit_io::smarts::Reaction) {
191/// omgkit_chem::sanitize(&mut mol).unwrap();
192/// omgkit_io::stereo::perceive_bond_stereo(&mut mol); // ← 别漏
193/// let props = MolProps::compute(&mol);
194/// let out = run_reactants(rxn, &[(mol, props)], 0, false);
195/// # let _ = out;
196/// # }
197/// ```
198///
199/// debug 构建下漏了会被 [`debug_assert`] 当场拦住;release 下不做这个检查。
200#[must_use]
201pub fn run_reactants(
202    reaction: &Reaction,
203    reactants: &[(MolBuilder, MolProps)],
204    max_products: usize,
205    atom_mapping: bool,
206) -> Vec<Outcome> {
207    debug_assert!(
208        !reactants
209            .iter()
210            .any(|(m, _)| omgkit_io::stereo::directions_not_perceived(m)),
211        "反应物里有双键的几何**方向键已经写明**、却没有感知过顺反 —— \
212         漏了 omgkit_io::stereo::perceive_bond_stereo。这样跑不会报错,\
213         但反应一旦删掉承载方向的那根单键,几何会静默丢失"
214    );
215    if reactants.len() != reaction.reactants.len() || reaction.products.is_empty() {
216        return Vec::new();
217    }
218
219    // 逐个反应物模板找匹配,再取笛卡尔积
220    let opts = MatchOptions {
221        max_matches: 0,
222        uniquify: false,
223        // 反应侧**不判**立体。
224        //
225        // 反应模板是跨工具流通的东西,读得更严会让现成的模板不再出产物,
226        // 而"少了产物"比"多了产物"难发现得多。子结构匹配那边默认判,
227        // 因为那里作者写什么就该算什么。
228        use_chirality: false,
229    };
230    let n = reaction.reactants.len();
231    let per_template: Vec<Vec<Vec<u32>>> = reaction
232        .reactants
233        .iter()
234        .zip(reactants)
235        .map(|(t, (mol, props))| substructure_matches(t, mol, props, opts))
236        .collect();
237    // 连通分量按分子算一次就够 —— 它与匹配到哪个位点无关,更与分配无关
238    let comps: Vec<Vec<u32>> = reactants.iter().map(|(m, _)| components(m)).collect();
239
240    // **恒等分配先试**:第 i 个模板配第 i 个分子。它只要 n 次子结构搜索,
241    // 所以顺序本来就对得上时,这条路的开销与先前一模一样。
242    if per_template.iter().all(|m| !m.is_empty()) {
243        let identity: Vec<usize> = (0..n).collect();
244        let out = outcomes_under(
245            reaction,
246            reactants,
247            &per_template,
248            &identity,
249            &comps,
250            max_products,
251            atom_mapping,
252        );
253        if !out.is_empty() {
254            return out;
255        }
256    }
257
258    // 恒等分配一个产物都给不出 —— 再看**别的一一对应**行不行。
259    // 到这里才把匹配表补满(最多再 n²−n 次搜索),恒等那条路一分钱不多花。
260    let mut table: Vec<Vec<Vec<Vec<u32>>>> = Vec::with_capacity(n);
261    for (t, tpl) in reaction.reactants.iter().enumerate() {
262        let mut row = Vec::with_capacity(n);
263        for (m, (mol, props)) in reactants.iter().enumerate() {
264            row.push(if m == t {
265                per_template[t].clone()
266            } else {
267                substructure_matches(tpl, mol, props, opts)
268            });
269        }
270        table.push(row);
271    }
272    let mut assign = vec![0usize; n];
273    let mut used = vec![false; n];
274    search_assignment(
275        reaction,
276        reactants,
277        &table,
278        &comps,
279        max_products,
280        atom_mapping,
281        0,
282        &mut assign,
283        &mut used,
284    )
285    .unwrap_or_default()
286}
287
288/// 在一个**确定的分配**下枚举匹配的笛卡尔积、造产物。
289///
290/// `assign[i]` 是第 i 个反应物模板落在第几个输入分子上;`per_template[i]` 是
291/// 那个模板在**那个分子**里的全部匹配。
292fn outcomes_under(
293    reaction: &Reaction,
294    reactants: &[(MolBuilder, MolProps)],
295    per_template: &[Vec<Vec<u32>>],
296    assign: &[usize],
297    comps: &[Vec<u32>],
298    max_products: usize,
299    atom_mapping: bool,
300) -> Vec<Outcome> {
301    let mut out = Vec::new();
302    let mut combo: Vec<usize> = vec![0; per_template.len()];
303    loop {
304        let mapping: Vec<&Vec<u32>> = combo
305            .iter()
306            .enumerate()
307            .map(|(i, &j)| &per_template[i][j])
308            .collect();
309        let built = build_products(reaction, reactants, &mapping, assign, comps);
310        out.push(stamp_atom_maps(reactants, built, atom_mapping));
311        if max_products != 0 && out.len() >= max_products {
312            return out;
313        }
314        // 进位
315        let mut i = 0;
316        loop {
317            if i == combo.len() {
318                return out;
319            }
320            combo[i] += 1;
321            if combo[i] < per_template[i].len() {
322                break;
323            }
324            combo[i] = 0;
325            i += 1;
326        }
327    }
328}
329
330/// 按字典序找**第一个能出产物的一一对应**。
331///
332/// 搜索的是"模板 ↔ 分子"的完美匹配,匹配表已经算好,所以每一步只是查表 ——
333/// 某个模板在剩下的分子里一个都匹配不上时当场剪掉,不往下展。
334#[allow(clippy::too_many_arguments)]
335fn search_assignment(
336    reaction: &Reaction,
337    reactants: &[(MolBuilder, MolProps)],
338    table: &[Vec<Vec<Vec<u32>>>],
339    comps: &[Vec<u32>],
340    max_products: usize,
341    atom_mapping: bool,
342    depth: usize,
343    assign: &mut Vec<usize>,
344    used: &mut Vec<bool>,
345) -> Option<Vec<Outcome>> {
346    if depth == assign.len() {
347        let per: Vec<Vec<Vec<u32>>> = assign
348            .iter()
349            .enumerate()
350            .map(|(t, &m)| table[t][m].clone())
351            .collect();
352        let out = outcomes_under(
353            reaction,
354            reactants,
355            &per,
356            assign,
357            comps,
358            max_products,
359            atom_mapping,
360        );
361        return if out.is_empty() { None } else { Some(out) };
362    }
363    for m in 0..used.len() {
364        if used[m] || table[depth][m].is_empty() {
365            continue;
366        }
367        used[m] = true;
368        assign[depth] = m;
369        if let Some(out) = search_assignment(
370            reaction,
371            reactants,
372            table,
373            comps,
374            max_products,
375            atom_mapping,
376            depth + 1,
377            assign,
378            used,
379        ) {
380            return Some(out);
381        }
382        used[m] = false;
383    }
384    None
385}
386
387/// 把若干个分子拼成一张图(不加任何键),返回拼好的图。
388///
389/// 只有 [`BondData`] 的 `begin`、`end`、`stereo_atoms` 带原子下标,要加偏移;
390/// [`AtomData`] 一个下标都不带 —— 手性是**相对邻居顺序**说的,不是相对下标。
391/// 正因如此,原子与键都必须**按原顺序**逐个搬:顺序一乱,每个原子的邻居序
392/// 跟着乱,手性标记的含义就变了,而拓扑、原子数、电荷全对,只有构型悄悄反了。
393fn concat(mols: &[(MolBuilder, MolProps)]) -> MolBuilder {
394    let n_atoms = mols.iter().map(|(m, _)| m.num_atoms()).sum();
395    let n_bonds = mols.iter().map(|(m, _)| m.num_bonds()).sum();
396    let mut out = MolBuilder::with_capacity(n_atoms, n_bonds);
397    for (m, _) in mols {
398        let base = u32::try_from(out.num_atoms()).unwrap_or(u32::MAX);
399        for a in m.atoms() {
400            out.add_atom_data(*a);
401        }
402        for b in m.bonds() {
403            let mut nb = *b;
404            nb.begin += base;
405            nb.end += base;
406            for s in &mut nb.stereo_atoms {
407                if *s != BondData::NO_STEREO_ATOM {
408                    *s += base;
409                }
410            }
411            let _ = out.add_bond_data(nb);
412        }
413    }
414    out
415}
416
417/// 把整个反应物侧当作**一张图**上的查询来跑,而不是按位置配对。
418///
419/// # 与 [`run_reactants`] 的分工
420///
421/// [`run_reactants`] 的契约是"N 个反应物模板 ↔ N 个输入分子,**一一对应**" ——
422/// 先恒等分配,给不出产物再搜别的对应关系,所以递入顺序不决定它跑不跑得起来。
423/// 但它仍然是**一对一**的:模板的片段数比分子数多时直接交白卷,而那正是
424/// **分子内反应**的形状 —— 两个片段落在同一个分子上。
425///
426/// 本函数把输入拼成一张图,让每个反应物模板在整张图上自由找位置,只要求
427/// 各模板匹配到的原子**两两不重叠**。于是
428///
429/// - 分子间:片段落在不同的连通分量上,与位置式的结果一致(不必再枚举排列)
430/// - 分子内:片段落在同一个分量的不同部位 —— 位置式表达不了的那一档
431/// - 盐:阳离子与阴离子是同一个分子的两个组分,模板可以同时碰到它们
432///
433/// 这与产物侧是同一条原则:**片数是(模板, 底物)共同的性质,不是模板的性质**。
434/// 产物侧早就这么做了(建进同一张图、按连通分量切开),这里只是把同一条原则
435/// 补到反应物侧。
436///
437/// # 代价
438///
439/// 匹配的搜索空间变大:位置式下第 i 个模板只在第 i 个分子里找,这里在整张图里
440/// 找,再靠不相交筛掉大部分组合。片段多、分子大时组合数会涨得很快,`max_products`
441/// 只截输出、不截枚举。要可预测的耗时就用 [`run_reactants`]。
442///
443/// # 调用前同样要先感知双键顺反
444///
445/// 理由与 [`run_reactants`] 完全相同,见那里。
446#[must_use]
447pub fn run_on_substrate(
448    reaction: &Reaction,
449    substrate: &[(MolBuilder, MolProps)],
450    max_products: usize,
451    atom_mapping: bool,
452) -> Vec<Outcome> {
453    debug_assert!(
454        !substrate
455            .iter()
456            .any(|(m, _)| omgkit_io::stereo::directions_not_perceived(m)),
457        "底物里有双键的几何**方向键已经写明**、却没有感知过顺反 —— \
458         漏了 omgkit_io::stereo::perceive_bond_stereo。理由见 run_reactants"
459    );
460    if substrate.is_empty() || reaction.reactants.is_empty() || reaction.products.is_empty() {
461        return Vec::new();
462    }
463
464    // 拼图之前先记下各分子的原子数 —— `discarded` 要按这个切回去,见
465    // `regroup_discarded`
466    let sizes: Vec<usize> = substrate.iter().map(|(m, _)| m.num_atoms()).collect();
467    let mol = concat(substrate);
468    let props = MolProps::compute(&mol);
469    let inputs = [(mol, props)];
470
471    // 反应侧不判立体,理由见 `run_reactants`
472    let opts = MatchOptions {
473        max_matches: 0,
474        uniquify: false,
475        use_chirality: false,
476    };
477    let per_template: Vec<Vec<Vec<u32>>> = reaction
478        .reactants
479        .iter()
480        .map(|t| substructure_matches(t, &inputs[0].0, &inputs[0].1, opts))
481        .collect();
482    if per_template.iter().any(Vec::is_empty) {
483        return Vec::new();
484    }
485
486    // 所有模板都落在这唯一一张图上
487    let home = vec![0usize; reaction.reactants.len()];
488    let n_atoms = inputs[0].0.num_atoms();
489    let comps: Vec<Vec<u32>> = inputs.iter().map(|(m, _)| components(m)).collect();
490
491    let mut out = Vec::new();
492    let mut combo: Vec<usize> = vec![0; per_template.len()];
493    let mut used = vec![false; n_atoms];
494    loop {
495        let mapping: Vec<&Vec<u32>> = combo
496            .iter()
497            .enumerate()
498            .map(|(i, &j)| &per_template[i][j])
499            .collect();
500        // 两个模板抢同一个原子是不合法的:位置式契约靠"分子各不相同"天然
501        // 保证了这一点,拼成一张图之后必须自己判。
502        used.iter_mut().for_each(|u| *u = false);
503        let disjoint = mapping.iter().all(|m| {
504            m.iter().all(|&a| {
505                let fresh = !used[a as usize];
506                used[a as usize] = true;
507                fresh
508            })
509        });
510        if disjoint {
511            let built = build_products(reaction, &inputs, &mapping, &home, &comps);
512            let mut outcome = stamp_atom_maps(&inputs, built, atom_mapping);
513            outcome.discarded = regroup_discarded(&outcome.discarded, &sizes);
514            out.push(outcome);
515            if max_products != 0 && out.len() >= max_products {
516                break;
517            }
518        }
519        // 进位
520        let mut i = 0;
521        loop {
522            if i == combo.len() {
523                return out;
524            }
525            combo[i] += 1;
526            if combo[i] < per_template[i].len() {
527                break;
528            }
529            combo[i] = 0;
530            i += 1;
531        }
532    }
533    out
534}
535
536/// 反应物侧一个映射号对应的**具体原子**:(第几个反应物, 原子下标)。
537type Anchor = (usize, u32);
538
539/// 反应物侧提前算好的三张表,产物构建全程只读。
540struct ReactantFacts {
541    /// 映射号 → 反应物里的那个原子
542    anchors: BTreeMap<u16, Anchor>,
543    /// 映射号 → 该原子在**反应物模板**里的度数。产物侧要拿它比,判断
544    /// "这个原子的连接有没有变" —— 变了的话氢数要重算,不能照抄。
545    degree: BTreeMap<u16, usize>,
546    /// 映射号 → **反应物模板**在这个原子上写的手性。产物侧要拿它比,
547    /// 见 [`ChiralityPlan`]。
548    chirality: BTreeMap<u16, Option<ChiralTag>>,
549    /// 映射号 → 该原子在**反应物模板**里的邻居次序,按映射号记(没号的记
550    /// `None`)。手性标记是相对邻居顺序的,两侧比标记之前要先比这个 ——
551    /// 见 [`template_order_is_odd`]。
552    neighbors: BTreeMap<u16, Vec<Option<u16>>>,
553}
554
555/// 一个模板原子的邻居次序,按映射号记。没有映射号的邻居记 `None`。
556fn neighbor_maps(template: &QueryMol, qi: u32) -> Vec<Option<u16>> {
557    template
558        .topology
559        .neighbors(qi)
560        .map(|(other, _)| map_number(&template.atoms[other as usize]))
561        .collect()
562}
563
564/// 反应物模板与产物模板在同一个原子上的邻居次序,置换是不是奇的。
565///
566/// 手性标记说的是"按**这张模板自己**的邻居顺序看过去"的构型。两侧的顺序不同时,
567/// 同一个 `@` 说的是两种构型 —— 所以"两侧写得一样不一样"要在同一个顺序下问,
568/// 不能直接比标记。典型形状:
569///
570/// ```text
571/// [F:2][C@H:1]([Cl:3])[Br:4]>>[Br:4][C@H:1]([Cl:3])[F:2]
572/// ```
573///
574/// 两侧都是 `@`,取代基一个没换,可产物侧把 F 与 Br 对调着写 —— 置换是奇的,
575/// 所以这条模板说的是**翻转**,不是保留。
576///
577/// # 只容忍每侧一个对不上的邻居
578///
579/// 一侧有、另一侧没有的邻居各至多一个时,它们互相顶替,对应关系唯一。多于一个
580/// 就不唯一了(两种配对宇称相反),这时返回 `None` 表示**不调整** —— 猜一个
581/// 只会把未定义变成另一个未定义。
582fn template_order_is_odd(react: &[Option<u16>], prod: &[Option<u16>]) -> Option<bool> {
583    // 度数不足 3 的中心谈不上四面体手性;两侧差出一个以上也没法对应
584    if react.len() < 3 || prod.len() < 3 || react.len().abs_diff(prod.len()) > 1 {
585        return None;
586    }
587    // 短的那侧补一个空位,代表"对面多出来的那个邻居"
588    let mut r: Vec<Option<u16>> = react.to_vec();
589    let mut p: Vec<Option<u16>> = prod.to_vec();
590    if r.len() < p.len() {
591        r.push(None);
592    } else if p.len() < r.len() {
593        p.push(None);
594    }
595    if r.iter().filter(|x| x.is_none()).count() > 1 || p.iter().filter(|x| x.is_none()).count() > 1
596    {
597        return None;
598    }
599    fill_missing(&mut r, &p)?;
600    fill_missing(&mut p, &r)?;
601    let enc = |v: &[Option<u16>]| -> Vec<u32> {
602        v.iter().map(|x| x.map_or(u32::MAX, u32::from)).collect()
603    };
604    omgkit_core::permutation_is_odd(&enc(&r), &enc(&p))
605}
606
607/// `want` 里有而 `have` 里没有的映射号,填进 `have` 唯一的那个空位。
608///
609/// 空位不够用就说明两侧的邻居对应不上,返回 `None`。
610fn fill_missing(have: &mut [Option<u16>], want: &[Option<u16>]) -> Option<()> {
611    for &elem in want.iter().flatten() {
612        if have.contains(&Some(elem)) {
613            continue;
614        }
615        let slot = have.iter().position(Option::is_none)?;
616        have[slot] = Some(elem);
617    }
618    Some(())
619}
620
621/// 产物这个原子的手性该怎么定。由**模板两侧写没写**决定,四种组合各有各的含义。
622///
623/// 模板作者用"写不写手性"表达反应对构型做了什么,这是一套约定,不是可以自由
624/// 发挥的地方:
625///
626/// | 反应物侧 | 产物侧 | 含义 |
627/// |---|---|---|
628/// | 没写 | 没写 | 模板没管这件事 —— 底物的构型原样带过来 |
629/// | 写了 | 没写 | 构型被破坏 —— 清掉 |
630/// | 没写 | 写了 | 构型是**新建**的 —— 照模板写死 |
631/// | 写了 | 写了 | 相对底物**保留**(两个标记相同)或**翻转**(不同) |
632///
633/// 最后一行最容易做错:两侧都写时,产物侧那个标记不是"要建成这个构型",而是
634/// "与反应物侧那个标记比一比"。照字面写死的话,产物的构型就与底物无关了 ——
635/// 同一个模板作用在一对对映体上会给出同一个产物,而正确答案是一对对映体。
636#[derive(Clone, Copy, PartialEq, Eq)]
637enum ChiralityPlan {
638    /// 两侧都没写 —— 继承底物,之后由 [`rebase_chirality`] 换参照系
639    Inherit,
640    /// 只有反应物侧写了 —— 清掉
641    Drop,
642    /// 只有产物侧写了 —— 照模板写死
643    Set,
644    /// 两侧都写了且相同 —— 保留底物的构型
645    Retain,
646    /// 两侧都写了且不同 —— 底物的构型翻一次
647    Invert,
648}
649
650impl ChiralityPlan {
651    /// `order_is_odd` 是两侧模板邻居次序的置换宇称,见
652    /// [`template_order_is_odd`]。次序对不上时给 `None`,那就只比标记。
653    fn decide(
654        reactant: Option<ChiralTag>,
655        product: Option<ChiralTag>,
656        order_is_odd: Option<bool>,
657    ) -> Self {
658        match (reactant, product) {
659            (None, None) => Self::Inherit,
660            (Some(_), None) => Self::Drop,
661            (None, Some(_)) => Self::Set,
662            // 标记一样、次序也一样 → 保留;两者恰好有一个反了 → 翻转。
663            // 只比标记的话,产物侧把两个取代基对调着写就会被当成"保留"。
664            (Some(r), Some(p)) => {
665                if (r == p) != order_is_odd.unwrap_or(false) {
666                    Self::Retain
667                } else {
668                    Self::Invert
669                }
670            }
671        }
672    }
673}
674
675/// 一个产物,连同"它的每个原子从哪个反应物原子来"。
676///
677/// 第二项按反应物下标分组,每组是 `反应物原子 → 产物原子`。产物侧新建的原子
678/// 不在表里 —— 它们没有反应物出处。
679type BuiltProduct = (MolBuilder, Vec<BTreeMap<u32, u32>>);
680
681/// `home[ti]` = 第 ti 个反应物模板匹配进了第几个输入分子。
682///
683/// 拆出这个间接层是为了让"一个模板 ↔ 一个分子"不再是写死的假设:分子内反应
684/// 里好几个模板落在同一个分子上,`home` 全指向同一个下标。位置式的
685/// [`run_reactants`] 传的是恒等映射,行为一字不变。
686/// 连通分量编号,每个原子一个。**每个分子只算一次**。
687///
688/// 分量结构是分子自己的性质,与匹配到哪个位点无关;而 `build_products` 每出一个
689/// outcome 就要判一次"哪些分量没有被匹配到",一个底物上有几十处匹配就要重算几十遍。
690/// 算一次传下去,省下的是常数乘以 outcome 数。
691pub(crate) fn components(mol: &MolBuilder) -> Vec<u32> {
692    let n = mol.num_atoms();
693    let mut comp = vec![u32::MAX; n];
694    let mut stack: Vec<u32> = Vec::new();
695    let mut next = 0u32;
696    for s in 0..n as u32 {
697        if comp[s as usize] != u32::MAX {
698            continue;
699        }
700        comp[s as usize] = next;
701        stack.push(s);
702        while let Some(a) = stack.pop() {
703            for (other, _) in mol.neighbors(a) {
704                if comp[other as usize] == u32::MAX {
705                    comp[other as usize] = next;
706                    stack.push(other);
707                }
708            }
709        }
710        next += 1;
711    }
712    comp
713}
714
715fn build_products(
716    reaction: &Reaction,
717    reactants: &[(MolBuilder, MolProps)],
718    matches: &[&Vec<u32>],
719    home: &[usize],
720    comps: &[Vec<u32>],
721) -> Vec<BuiltProduct> {
722    // 被模板匹配到的原子(不论有没有映射号),这些不再作为"模板外的部分"搬运
723    let mut matched: Vec<Vec<bool>> = reactants
724        .iter()
725        .map(|(m, _)| vec![false; m.num_atoms()])
726        .collect();
727    // 反应物模板**亲自匹配到**的那些底物键,按底物的键下标索引。
728    //
729    // 只有这些键归产物模板管;模板没看见的键它无权删,理由见 `carry_over`。
730    let mut template_bonds: Vec<Vec<bool>> = reactants
731        .iter()
732        .map(|(m, _)| vec![false; m.num_bonds()])
733        .collect();
734
735    let mut facts = ReactantFacts {
736        anchors: BTreeMap::new(),
737        degree: BTreeMap::new(),
738        chirality: BTreeMap::new(),
739        neighbors: BTreeMap::new(),
740    };
741
742    for (ti, template) in reaction.reactants.iter().enumerate() {
743        // 第 ti 个模板落在第几个输入分子上。位置式契约下就是 ti 自己;把两个
744        // 模板放到同一个分子上(分子内反应)时,好几个 ti 会指向同一个下标。
745        let ri = home[ti];
746        for qb in template.topology.bonds() {
747            let (a, b) = (matches[ti][qb.begin as usize], matches[ti][qb.end as usize]);
748            if let Some(bi) = reactants[ri].0.bond_between(a, b) {
749                template_bonds[ri][bi as usize] = true;
750            }
751        }
752        for (qi, &target) in matches[ti].iter().enumerate() {
753            matched[ri][target as usize] = true;
754            if let Some(n) = map_number(&template.atoms[qi]) {
755                facts.anchors.entry(n).or_insert((ri, target));
756                facts
757                    .degree
758                    .entry(n)
759                    .or_insert_with(|| template.topology.degree(qi as u32));
760                facts
761                    .chirality
762                    .entry(n)
763                    .or_insert_with(|| required_chirality(&template.atoms[qi]));
764                facts
765                    .neighbors
766                    .entry(n)
767                    .or_insert_with(|| neighbor_maps(template, qi as u32));
768            }
769        }
770    }
771
772    // 所有产物模板建进**同一张图**,未匹配的部分只搬一次,最后按连通分量切开。
773    //
774    // 逐产物各建一张图是错的:两个产物模板的锚点若仍通过未匹配的原子相连
775    // (分子内反应、断环键都是这样),那批原子会被**复制**进每一个产物 ——
776    // 质量当场不守恒。分子内环化的逆向尤其明显:打开一个环并不产生两个分子。
777    let mut out = MolBuilder::new();
778    let mut from_reactant: Vec<BTreeMap<u32, u32>> =
779        reactants.iter().map(|_| BTreeMap::new()).collect();
780    // 手性已经由模板定死、不再定基的那些原子,见 `rebase_chirality`。
781    let mut settled_chirality: BTreeSet<u32> = BTreeSet::new();
782
783    for pt in &reaction.products {
784        emit_template(
785            pt,
786            reactants,
787            &facts,
788            &mut out,
789            &mut from_reactant,
790            &mut settled_chirality,
791        );
792    }
793
794    // 模板之外的部分:每个原子只搬一次。
795    //
796    // 先埋旁观组分的种子,再走遍历 —— 两步共用同一趟 `carry_over`,键的排序
797    // 纪律与重定基因此完全一致。
798    for (ti, (mol, _)) in reactants.iter().enumerate() {
799        seed_spectators(
800            mol,
801            &comps[ti],
802            &matched[ti],
803            &mut from_reactant[ti],
804            &mut out,
805        );
806        carry_over(
807            mol,
808            &matched[ti],
809            &template_bonds[ti],
810            &mut from_reactant[ti],
811            &mut out,
812        );
813    }
814    // 手性与顺反都要在切分**之前**定基 —— 切分保持邻居的相对顺序,定基却要看全图
815    for (ti, (mol, _)) in reactants.iter().enumerate() {
816        rebase_chirality(mol, &from_reactant[ti], &settled_chirality, &mut out);
817        rebase_bond_stereo(mol, &from_reactant[ti], &mut out);
818    }
819
820    split_components(&out, &from_reactant)
821}
822
823/// 模板里哪些方向键**真的表了态**,按模板键下标索引。
824///
825/// # 一根方向键单独出现时什么也没说
826///
827/// 顺反要靠双键**两端各一根**方向键才定得下来。`F/C=C/F` 是反式;而 `F/C=CF`
828/// 里那根 `/` 定不了任何东西 —— 它只说了"这根单键画在右上",另一端没画,
829/// 两个取代基的相对位置仍然未知。SMILES 与 SMARTS 在这一点上是同一套规则。
830///
831/// # 照抄一根孤立方向键会把参照系换掉
832///
833/// 孤立的那根抄进产物之后,并不会孤立地待着:双键另一侧的键多半是从底物
834/// **继承**来的,它带着底物的方向。于是产物里凑出了一对方向 —— 一根来自
835/// 模板的书写顺序,一根来自底物的书写顺序,两个互不相干的参照系。凑出来的
836/// 几何是任意的:底物明明是反式,产物可以变成顺式,而拓扑、原子数、电荷
837/// 全对,只有几何被悄悄换掉。这正是本项目最难发现的那一类错。
838///
839/// rdchiral 从 USPTO-50k 抽出的模板大量落在这一档:两侧各写一根孤立方向键,
840/// 且书写朝向一正一反(反应物侧写 `[C:2]/[C:4]=`,产物侧写 `=[C:4]/[C:2]`),
841/// 于是每一条都恰好翻一次。实测正向 100 条以上因此翻错。
842///
843/// # 判据
844///
845/// 双键"被模板定死"= 它两端**各自**还挂着至少一根写了方向的键。方向键"算数"
846/// = 它挨着至少一根被定死的双键。两条都不满足时按没写方向处理,几何交回给
847/// 继承那一支 —— 那一支两侧都取自同一个底物,参照系是一致的。
848fn honoured_directions(template: &QueryMol) -> Vec<bool> {
849    let bonds = template.topology.bonds();
850    let has_dir: Vec<bool> = template
851        .bonds
852        .iter()
853        .map(|e| bond_direction_from(e) != BondDirection::None)
854        .collect();
855    // 这个原子上,除了 `skip` 那根键之外还有没有写了方向的键
856    let flanked = |atom: u32, skip: usize| {
857        template
858            .topology
859            .neighbors(atom)
860            .any(|(_, bi)| bi as usize != skip && has_dir[bi as usize])
861    };
862    let determined: Vec<bool> = (0..bonds.len())
863        .map(|bi| {
864            product_bond_from(&template.bonds[bi]) == ProductBond::Fixed(BondOrder::Double)
865                && flanked(bonds[bi].begin, bi)
866                && flanked(bonds[bi].end, bi)
867        })
868        .collect();
869    (0..bonds.len())
870        .map(|bi| {
871            has_dir[bi]
872                && [bonds[bi].begin, bonds[bi].end].iter().any(|&a| {
873                    template
874                        .topology
875                        .neighbors(a)
876                        .any(|(_, ob)| ob as usize != bi && determined[ob as usize])
877                })
878        })
879        .collect()
880}
881
882/// 把一个产物模板的原子与键建进共享图。
883fn emit_template(
884    template: &QueryMol,
885    reactants: &[(MolBuilder, MolProps)],
886    facts: &ReactantFacts,
887    out: &mut MolBuilder,
888    from_reactant: &mut [BTreeMap<u32, u32>],
889    settled_chirality: &mut BTreeSet<u32>,
890) {
891    let mut from_template: Vec<u32> = Vec::with_capacity(template.num_atoms());
892    let mut anchor_of: Vec<Option<Anchor>> = Vec::with_capacity(template.num_atoms());
893
894    // 一、产物模板里的原子
895    for (qi, expr) in template.atoms.iter().enumerate() {
896        let anchor = map_number(expr)
897            .and_then(|n| facts.anchors.get(&n))
898            .copied();
899        anchor_of.push(anchor);
900        let base = match anchor {
901            // 有映射号且反应物侧找得到 —— 继承原子,再按模板改写
902            Some((ti, ai)) => reactants[ti].0.atoms()[ai as usize],
903            // 没有 —— 新建
904            None => AtomData::new(0),
905        };
906        // 该原子在产物模板里的度数与它在反应物模板里的度数是否一致
907        let degree_kept = map_number(expr)
908            .and_then(|n| facts.degree.get(&n).copied())
909            .is_some_and(|d| d == template.topology.degree(qi as u32));
910        let plan = ChiralityPlan::decide(
911            map_number(expr)
912                .and_then(|n| facts.chirality.get(&n).copied())
913                .flatten(),
914            required_chirality(expr),
915            map_number(expr)
916                .and_then(|n| facts.neighbors.get(&n))
917                .and_then(|r| template_order_is_odd(r, &neighbor_maps(template, qi as u32))),
918        );
919        let idx = out.add_atom_data(apply_template(base, expr, degree_kept, plan));
920        // 模板在这个原子上表过态的,标记就此定死,不再定基。
921        //
922        // 定基换的是"反应物邻居序 → 产物邻居序",只对**继承来的**标记成立。
923        // 模板一旦发话,产物侧那个标记说的就是产物自己参照系里的构型,再套一次
924        // 反应物侧的置换等于凭空多翻一道。`Drop` 那档没有标记可言,也不必进来。
925        if plan == ChiralityPlan::Set {
926            settled_chirality.insert(idx);
927        }
928        from_template.push(idx);
929        if let Some((ti, ai)) = anchor {
930            from_reactant[ti].insert(ai, idx);
931        }
932    }
933
934    // 二、产物模板里的键
935    let honoured = honoured_directions(template);
936    for (bi, expr) in template.bonds.iter().enumerate() {
937        let b = template.topology.bonds()[bi];
938        // 键级有三种来源,见 [`ProductBond`]。原子已经在上一段建完了,所以
939        // "两端都芳香吗"此刻问得出来。
940        let order = match product_bond_from(expr) {
941            ProductBond::Fixed(o) => o,
942            ProductBond::FollowAromaticity => {
943                let aromatic = |ti: u32| {
944                    out.atoms()[from_template[ti as usize] as usize]
945                        .flags
946                        .contains(omgkit_core::AtomFlags::AROMATIC)
947                };
948                if aromatic(b.begin) && aromatic(b.end) {
949                    BondOrder::Aromatic
950                } else {
951                    BondOrder::Single
952                }
953            }
954            ProductBond::Inherit => {
955                match (anchor_of[b.begin as usize], anchor_of[b.end as usize]) {
956                    (Some((t1, a1)), Some((t2, a2))) if t1 == t2 => {
957                        inherited_order(&reactants[t1].0, a1, a2)
958                    }
959                    // 底物里没有这根键,模板又没说建什么 —— 谁都没表过态。
960                    _ => BondOrder::Unspecified,
961                }
962            }
963        };
964        // 配位键的**朝向靠端点顺序表达**:`begin` 必须是给电子的一端。
965        //
966        // 而查询侧不是这么存的:`A<-B` 的端点按书写顺序记成 (A, B),由基元
967        // `DativeReversed` 去区分朝向 —— 匹配时那样最直接。照搬端点建产物,
968        // 箭头就反了:模板写 `>>[Fe:1]<-O(C)C`(氧给铁)会建成铁给氧,
969        // 而"接受"的配位键计入受体的价,那个氧当场超价。
970        let (tb, te) = if is_dative_reversed(expr) {
971            (b.end, b.begin)
972        } else {
973            (b.begin, b.end)
974        };
975        let mut bd = BondData::new(
976            from_template[tb as usize],
977            from_template[te as usize],
978            order,
979        );
980        // 芳香键的标志位与键级始终同步
981        bd.flags.set(
982            omgkit_core::BondFlags::AROMATIC,
983            order == BondOrder::Aromatic,
984        );
985        // 方向有两个可能的来源,**模板真的表了态时**它优先。
986        //
987        // 一、模板自己写了 `/` 或 `\`,而且写成了**能定下几何的那种**:
988        //    `>>C/[C:1]=[C:2]/C` 说的就是"新生成的双键是反式"。它相对模板键的
989        //    begin → end,而新键的两端正是模板两端的像,朝向一致,直接照抄。
990        //
991        //    孤零零一根方向键不算表态,判据见 `honoured_directions` —— 它
992        //    什么几何也没定,却会把另一侧继承来的方向拽进另一个参照系。
993        //
994        // 二、模板没写方向,但两端都源自同一个反应物、那里本来就有这根键。
995        //    方向表达的是**旁边那根双键**两侧取代基的相对位置 —— 反应没碰那根
996        //    双键的话,这个关系就该原样留着。丢了它,产物从确定的顺式(或反式)
997        //    退化成未指定,是实打实的丢信息。
998        //
999        //    这一支的朝向要对齐:存的方向相对源键的 begin → end,而新键的 begin
1000        //    对应的是模板端点 b.begin 的出处,两者不一定同向。
1001        let from_template_dir = if honoured[bi] {
1002            bond_direction_from(expr)
1003        } else {
1004            BondDirection::None
1005        };
1006        bd.direction = if from_template_dir != BondDirection::None {
1007            from_template_dir
1008        } else if let (Some((t1, a1)), Some((t2, a2))) =
1009            (anchor_of[b.begin as usize], anchor_of[b.end as usize])
1010        {
1011            if t1 == t2 {
1012                inherited_direction(&reactants[t1].0, a1, a2)
1013            } else {
1014                BondDirection::None
1015            }
1016        } else {
1017            BondDirection::None
1018        };
1019        let _ = out.add_bond_data(bd);
1020    }
1021}
1022
1023/// 把共享图按连通分量切成一个个产物分子。
1024///
1025/// # 产物分子数由**连通性**决定,不等于产物模板数
1026///
1027/// 产物模板描述的是反应中心的片段。片段之间断没断开,要看底物 —— 模板之外的
1028/// 原子可能仍把它们连着。分子内环化的逆向就是这样:模板写成两个片段,可打开
1029/// 一个环并不产生两个分子,原子一个不多不少。
1030///
1031/// # 邻居的相对顺序必须保住
1032///
1033/// 手性标记相对邻居存储顺序,而切分是在定基**之后**做的。按原下标升序放原子、
1034/// 按原键序建键,每个原子的邻居相对顺序就与切分前一致,标记照样成立。
1035fn split_components(
1036    shared: &MolBuilder,
1037    from_reactant: &[BTreeMap<u32, u32>],
1038) -> Vec<BuiltProduct> {
1039    let n = shared.num_atoms();
1040    let mut comp = vec![usize::MAX; n];
1041    let mut n_comp = 0usize;
1042    let mut stack: Vec<u32> = Vec::new();
1043    for s in 0..n as u32 {
1044        if comp[s as usize] != usize::MAX {
1045            continue;
1046        }
1047        comp[s as usize] = n_comp;
1048        stack.push(s);
1049        while let Some(a) = stack.pop() {
1050            for (other, _) in shared.neighbors(a) {
1051                if comp[other as usize] == usize::MAX {
1052                    comp[other as usize] = n_comp;
1053                    stack.push(other);
1054                }
1055            }
1056        }
1057        n_comp += 1;
1058    }
1059
1060    let mut mols: Vec<MolBuilder> = (0..n_comp).map(|_| MolBuilder::new()).collect();
1061    // 共享图下标 → 该分量里的下标
1062    let mut local = vec![u32::MAX; n];
1063    for a in 0..n as u32 {
1064        let c = comp[a as usize];
1065        local[a as usize] = mols[c].add_atom_data(shared.atoms()[a as usize]);
1066    }
1067    for b in shared.bonds() {
1068        let c = comp[b.begin as usize];
1069        let mut nb = *b;
1070        nb.begin = local[b.begin as usize];
1071        nb.end = local[b.end as usize];
1072        nb.stereo_atoms = [
1073            translate_stereo_atom(b.stereo_atoms[0], &local),
1074            translate_stereo_atom(b.stereo_atoms[1], &local),
1075        ];
1076        let _ = mols[c].add_bond_data(nb);
1077    }
1078
1079    // 出处表也要按分量拆开
1080    let mut tables: Vec<Vec<BTreeMap<u32, u32>>> = (0..n_comp)
1081        .map(|_| from_reactant.iter().map(|_| BTreeMap::new()).collect())
1082        .collect();
1083    for (ti, table) in from_reactant.iter().enumerate() {
1084        for (&src, &dst) in table {
1085            let c = comp[dst as usize];
1086            tables[c][ti].insert(src, local[dst as usize]);
1087        }
1088    }
1089
1090    mols.into_iter().zip(tables).collect()
1091}
1092
1093/// 参照原子的下标换算。哨兵值不换算 —— 它不是下标。
1094fn translate_stereo_atom(idx: u32, local: &[u32]) -> u32 {
1095    if idx == BondData::NO_STEREO_ATOM {
1096        return BondData::NO_STEREO_ATOM;
1097    }
1098    local
1099        .get(idx as usize)
1100        .copied()
1101        .filter(|&v| v != u32::MAX)
1102        .unwrap_or(BondData::NO_STEREO_ATOM)
1103}
1104
1105/// 按运行时的原子对应关系,给反应物副本与产物打上映射号。
1106///
1107/// 规则连同理由写在 [`run_reactants`] 的文档里(那是调用方看得到的地方)。
1108/// 这里只补两处实现上的要点:
1109///
1110/// - 对应关系来自 [`BuiltProduct`] 的第二项,它由模板匹配与搬运共同填出;
1111///   产物侧新建的原子不在表里,自然拿不到号
1112/// - `first_home` 用 `BTreeMap` 而不是 `HashMap`:它的迭代顺序正好是发号要的
1113///   `(反应物, 原子下标)` 升序,顺手把"号必须确定"这条要求解决掉
1114///
1115/// `atom_mapping` 为假时直接把产物取出来,反应物侧留空,一次复制都不做。
1116fn stamp_atom_maps(
1117    reactants: &[(MolBuilder, MolProps)],
1118    built: Vec<BuiltProduct>,
1119    atom_mapping: bool,
1120) -> Outcome {
1121    let discarded = discarded_atoms(reactants, &built);
1122    if !atom_mapping {
1123        return Outcome {
1124            products: built.into_iter().map(|(m, _)| m).collect(),
1125            reactants: Vec::new(),
1126            discarded,
1127        };
1128    }
1129
1130    let mut products: ProductSet = Vec::with_capacity(built.len());
1131    // (反应物下标, 反应物原子) → (第几个产物, 产物原子)。BTreeMap 的迭代顺序
1132    // 正好是发号要的顺序;`or_insert` 让先到的产物赢。
1133    let mut first_home: BTreeMap<(usize, u32), (usize, u32)> = BTreeMap::new();
1134    for (pi, (mol, per_reactant)) in built.into_iter().enumerate() {
1135        for (ti, table) in per_reactant.iter().enumerate() {
1136            for (&src, &dst) in table {
1137                first_home.entry((ti, src)).or_insert((pi, dst));
1138            }
1139        }
1140        products.push(mol);
1141    }
1142
1143    let mut mapped: Vec<MolBuilder> = reactants.iter().map(|(m, _)| m.clone()).collect();
1144    for m in &mut mapped {
1145        for i in 0..m.num_atoms() as u32 {
1146            if let Some(a) = m.atom_mut(i) {
1147                a.atom_map = 0;
1148            }
1149        }
1150    }
1151
1152    let mut next: u32 = 1;
1153    for (&(ti, src), &(pi, dst)) in &first_home {
1154        // u16 装不下更多号了。继续发会绕回去,把两个不同的原子说成同一个,
1155        // 那比留几个原子无号糟得多。
1156        let Ok(n) = u16::try_from(next) else { break };
1157        // 两侧都写得进去才发 —— 单边的号正是本函数要避免的东西
1158        if mapped[ti].atoms().get(src as usize).is_none()
1159            || products[pi].atoms().get(dst as usize).is_none()
1160        {
1161            continue;
1162        }
1163        if let Some(a) = mapped[ti].atom_mut(src) {
1164            a.atom_map = n;
1165        }
1166        if let Some(a) = products[pi].atom_mut(dst) {
1167            a.atom_map = n;
1168        }
1169        next += 1;
1170    }
1171
1172    Outcome {
1173        products,
1174        reactants: mapped,
1175        discarded,
1176    }
1177}
1178
1179/// 把拼接图上的丢弃原子下标切回**各个输入分子**的下标。
1180///
1181/// [`run_on_substrate`] 把输入拼成一张图跑,于是 `discarded` 只有一条、下标是
1182/// 拼接图的。可 [`Outcome::discarded`] 的契约是"第 i 个输入分子的原子下标" ——
1183/// **契约不该随入口而变**:调用方拿到 `discarded` 时不该还要先知道上游走的是
1184/// 哪个函数。
1185///
1186/// 不切回去的后果不是报错,是**静默算错**:下游拿拼接图的下标去索引原始分子,
1187/// 越界的被悄悄跳过,片段少了原子,账跟着错,而每一步都跑得通。
1188fn regroup_discarded(flat: &[Vec<u32>], sizes: &[usize]) -> Vec<Vec<u32>> {
1189    let mut out: Vec<Vec<u32>> = sizes.iter().map(|_| Vec::new()).collect();
1190    for a in flat.iter().flatten() {
1191        let mut rest = *a as usize;
1192        for (i, &n) in sizes.iter().enumerate() {
1193            if rest < n {
1194                out[i].push(u32::try_from(rest).unwrap_or(u32::MAX));
1195                break;
1196            }
1197            rest -= n;
1198        }
1199    }
1200    out
1201}
1202
1203/// 每个输入分子里没有进入任何产物的原子。
1204///
1205/// 出处表是**按产物分量**拆开的,所以"进了产物"要对所有产物取并集再补 ——
1206/// 只看某一个产物会把搬进别的片段的原子误记成丢弃。
1207fn discarded_atoms(reactants: &[(MolBuilder, MolProps)], built: &[BuiltProduct]) -> Vec<Vec<u32>> {
1208    let mut kept: Vec<Vec<bool>> = reactants
1209        .iter()
1210        .map(|(m, _)| vec![false; m.num_atoms()])
1211        .collect();
1212    for (_, per_reactant) in built {
1213        for (ti, table) in per_reactant.iter().enumerate() {
1214            for &src in table.keys() {
1215                if let Some(slot) = kept[ti].get_mut(src as usize) {
1216                    *slot = true;
1217                }
1218            }
1219        }
1220    }
1221    kept.iter()
1222        .map(|flags| {
1223            flags
1224                .iter()
1225                .enumerate()
1226                .filter(|&(_, &k)| !k)
1227                .map(|(i, _)| u32::try_from(i).unwrap_or(u32::MAX))
1228                .collect()
1229        })
1230        .collect()
1231}
1232
1233/// 手性标记是相对**邻居存储顺序**的,而产物的存储顺序与反应物不同 ——
1234/// 模板的键先建、搬过来的键后建,顺序被打乱了。
1235///
1236/// 不重新定基的话产物会是镜像分子:原子数、键集合、连通性全对,只有手性反了,
1237/// 纯拓扑比对永远发现不了。这与写出器里那次宇称换算是同一类问题。
1238///
1239/// 邻居**数目变了**的中心不处理:取代基增减之后手性本就没有定义,
1240/// 硬翻一次只会把一个未定义的值变成另一个未定义的值。
1241///
1242/// # 配位几何(`@SP`/`@TB`/`@OH`)走另一套换算
1243///
1244/// 四面体只有两种排列,换参照系就是"置换是奇是偶"。配位几何有 3 / 20 / 30 种,
1245/// 奇偶说不清它 —— 换算表在 [`omgkit_core::polyhedron::renumber`],写出器与
1246/// 规范化早就在用它,这里先前没接。
1247///
1248/// 后果:产物的邻居存储顺序**一定**变了(模板的键先建、搬运来的键后建),
1249/// 而 `@SP1` 原样照抄进一个不同的参照系,指的是另一个几何异构体。
1250///
1251/// # 要定基的是**产物**当前的标记,不是反应物的
1252///
1253/// 产物原子的标记未必等于它继承来的那个:模板可以写死一个构型,也可以刻意
1254/// 不写(那时 [`apply_template`] 会把它清掉)。拿反应物的标记来写,等于把
1255/// 模板刚做的决定又覆盖回去 —— 清掉的会被恢复,写死的会被换成继承值。
1256///
1257/// 换参照系用的是**邻居的对应关系**,与标记取值无关,所以这两件事可以分开:
1258/// 置换从两侧的邻居顺序算,取值从产物当前的标记取。
1259///
1260/// # 模板表过态的原子不定基
1261///
1262/// 定基换的是"反应物的邻居序 → 产物的邻居序",这只对模板**没管**的原子成立 ——
1263/// 它们的标记原样继承自反应物,记在反应物的参照系里。
1264///
1265/// 模板一旦在这个原子上写了手性([`ChiralityPlan`] 的后三档),标记说的就是
1266/// 产物自己参照系里的构型:`Set` 直接来自产物模板,而产物模板的键正是按模板
1267/// 顺序先建进产物图的;`Retain`/`Invert` 表达的是"与底物同构型/反构型",
1268/// 也是就产物这张图而言。再套一次反应物侧的置换,等于凭空多翻一道。
1269///
1270/// 这一档的触发面在小语料上是 0(22 条模板没有一条在产物侧写手性),
1271/// 真实模板里却很常见:立体专一的反应就是靠产物侧的 `@` 表达构型的。
1272/// 判据见 `harness/check_product_chirality.py`。
1273fn rebase_chirality(
1274    mol: &MolBuilder,
1275    kept: &BTreeMap<u32, u32>,
1276    settled_chirality: &BTreeSet<u32>,
1277    out: &mut MolBuilder,
1278) {
1279    for (&src, &dst) in kept {
1280        if settled_chirality.contains(&dst) {
1281            continue;
1282        }
1283        let tag = out.atoms()[dst as usize].chiral_tag;
1284        if tag == ChiralTag::Unspecified {
1285            continue;
1286        }
1287        let after: Vec<u32> = out.neighbors(dst).map(|(other, _)| other).collect();
1288        if !tag.is_tetrahedral() {
1289            rebase_coordination(mol, src, dst, kept, &after, out);
1290            continue;
1291        }
1292        // 反应物侧的邻居,按原顺序换算成产物下标。空出来的槽位下面补。
1293        //
1294        // 槽位空不空要看"**还连不连在这个中心上**",不是看那个原子有没有进产物。
1295        // 反应可以把一个邻居挪到别的产物片段去、同时接上一个新的:
1296        // `[C@@H:1]-[O:2]>>C-C(=O)-O-[C@@H:1].[OH:2]` 里 O:2 活着,只是不再连着
1297        // :1 了。只判"进没进产物"的话这个槽位算被占着,于是 before 里有个 after
1298        // 里没有的原子,置换算不出来(多重集都不同),重定基被**静默跳过** ——
1299        // 标记原样照抄,而产物的邻居顺序早变了,得到的是镜像。
1300        let slots: Vec<Option<u32>> = mol
1301            .neighbors(src)
1302            .map(|(other, _)| kept.get(&other).copied().filter(|p| after.contains(p)))
1303            .collect();
1304        let Some((before, after)) = align_for_rebase(&slots, &after) else {
1305            continue;
1306        };
1307        if omgkit_core::permutation_is_odd(&before, &after) == Some(true) {
1308            if let Some(a) = out.atom_mut(dst) {
1309                a.chiral_tag = tag.inverted();
1310            }
1311        }
1312    }
1313}
1314
1315/// 配位几何的重定基:把排列序号从反应物的邻居序换算到产物的邻居序。
1316///
1317/// 两侧的配体必须是同一组(不增不减)—— 增减了的话这个标记本就没有意义。
1318/// 换算不出来就把标记整个丢掉:**表达不出来要说出来,照抄一个错的序号是撒谎。**
1319fn rebase_coordination(
1320    mol: &MolBuilder,
1321    src: u32,
1322    dst: u32,
1323    kept: &BTreeMap<u32, u32>,
1324    after: &[u32],
1325    out: &mut MolBuilder,
1326) {
1327    let tag = out.atoms()[dst as usize].chiral_tag;
1328    let perm = out.atoms()[dst as usize].stereo_perm;
1329    let before: Vec<u32> = mol
1330        .neighbors(src)
1331        .filter_map(|(other, _)| kept.get(&other).copied())
1332        .filter(|p| after.contains(p))
1333        .collect();
1334    let renumbered = if perm == 0 || before.len() != after.len() {
1335        None
1336    } else {
1337        omgkit_core::polyhedron::renumber(tag, perm, &before, after)
1338    };
1339    if let Some(a) = out.atom_mut(dst) {
1340        match renumbered {
1341            Some(p) => a.stereo_perm = p,
1342            None => {
1343                a.stereo_perm = 0;
1344                a.chiral_tag = ChiralTag::Unspecified;
1345            }
1346        }
1347    }
1348}
1349
1350/// 代表**隐式氢**的哨兵。氢不是图里的节点,可它占着四面体的一个位置,
1351/// 换参照系时必须算进去。取 `u32::MAX` 是因为真实原子下标不可能是它。
1352pub(crate) const IMPLICIT_H: u32 = u32::MAX;
1353
1354/// 把反应物侧与产物侧的邻居顺序对齐成两条等长、同多重集的序列,供求宇称用。
1355///
1356/// # 度数变了不等于放弃
1357///
1358/// 取代基被**隐式氢**接管是常事 —— 脱保护、脱羧、脱卤都是:
1359///
1360/// ```text
1361/// C-C(-C)(-C)-O-C(=O)-[C@@;H0;D4:1](-[C:2])(-[N:3])-[C:4]=[O:6]
1362///                  >> [C:4](=[O:6])-[C@H;D3:1](-[C:2])-[N:3]
1363/// ```
1364///
1365/// 中心从 D4H0 变成 D3H1。因为"长度对不上"就跳过重定基的话,标记会原样留在
1366/// **反应物的**参照系里,而产物的邻居顺序已经变了 —— 于是拿到镜像。拓扑、
1367/// 原子数、电荷全对,只有构型反了。USPTO-50k 上实测有这一档。
1368///
1369/// # 氢占着腾出来的那个位置
1370///
1371/// 所以做法是:少邻居的那一侧把哨兵**插在下标 1**,也就是紧跟第一个邻居 ——
1372/// 这与解析器留下的存储约定一致(`N[C@H](O)F` 的存储序是 `(N, O, F)`,标记
1373/// 说的是"从 N 看过去,H、O、F 依次逆时针")。
1374///
1375/// 插在哪一头其实**不影响结果**:四个邻居时把一个元素从首挪到尾是个三轮换,
1376/// 宇称不变。选下标 1 只是为了与约定对得上,读代码的人不必再推一遍。
1377///
1378/// 两个方向对称:取代基被氢接管时哨兵进**产物侧**,反应物侧原本的隐式氢被
1379/// 新邻居顶替时哨兵进**反应物侧**。
1380///
1381/// 只处理恰好差一个、且对齐后凑满四个邻居的情形 —— "对换一次就翻转"这条规则
1382/// 只对四面体成立。
1383pub(crate) fn align_for_rebase(
1384    slots: &[Option<u32>],
1385    after: &[u32],
1386) -> Option<(Vec<u32>, Vec<u32>)> {
1387    if let Some(before) = fill_replaced_slots(slots, after) {
1388        if before.len() == after.len() {
1389            return Some((before, after.to_vec()));
1390        }
1391    }
1392    let vacated = slots.iter().filter(|s| s.is_none()).count();
1393    let occupied = slots.len() - vacated;
1394    if vacated == 1 && occupied == after.len() && slots.len() == 4 {
1395        // 反应物侧多一个邻居:它在产物里被隐式氢接管
1396        let before: Vec<u32> = slots.iter().map(|s| s.unwrap_or(IMPLICIT_H)).collect();
1397        let mut aligned = after.to_vec();
1398        aligned.insert(1, IMPLICIT_H);
1399        return Some((before, aligned));
1400    }
1401    if vacated == 0 && after.len() == slots.len() + 1 && after.len() == 4 {
1402        // 产物侧多一个邻居:它顶了反应物侧原本的隐式氢
1403        let taken: BTreeSet<u32> = slots.iter().flatten().copied().collect();
1404        let mut fresh = after.iter().filter(|a| !taken.contains(a));
1405        let new = *fresh.next()?;
1406        if fresh.next().is_some() {
1407            return None;
1408        }
1409        let mut before: Vec<u32> = slots.iter().flatten().copied().collect();
1410        before.insert(1, new);
1411        return Some((before, after.to_vec()));
1412    }
1413    None
1414}
1415
1416/// 把"被替换掉的取代基"那些空槽用产物侧新建的原子填上,保持原顺序。
1417///
1418/// # 为什么不能直接把空槽丢掉
1419///
1420/// `[C:1][OH]>>[C:1]Cl` 会删掉氧、新建一个氯。中心的度数没变,变的是邻居的
1421/// **身份**。把氧丢掉的话,反应物侧只剩 2 个邻居而产物侧有 3 个,长度对不上,
1422/// 重定基整个被跳过 —— 标记原样照抄,而产物侧的邻居顺序已经变了,于是手性反了。
1423///
1424/// 取代反应的几何含义是新取代基**占据被替换者原来的空间位置**,中心的构型
1425/// 不因此改变。所以这里按原顺序把空槽填上,新原子依次顶替。
1426///
1427/// # 一次换掉两个取代基:这里给的是一个**约定**
1428///
1429/// "新的顶替旧的"在只换一个时是唯一的。同一个中心上一次换掉两个就不唯一了:
1430///
1431/// ```text
1432/// [C@:1]([F:2])([Cl:3])([Br:4])[I:5]>>[C@:1]([N])([O])([I:5])[Br:4]
1433/// ```
1434///
1435/// N 顶 F 还是顶 Cl,两种配对宇称相反,得到的是一对对映体,而拓扑本身说不出是
1436/// 哪一个。本实现按**产物侧的邻居顺序**依次顶替。
1437///
1438/// 要强调的是:另一种做法("对应关系不唯一就干脆不定基")并不是"不猜",而是
1439/// 猜了恒等置换 —— 标记原样留在底物的参照系里,同样是一个约定。两者都推不出来,
1440/// 只能拿记录裁。真实语料上这两种约定分歧 3 条,记录判 **2:1** 支持这里的做法
1441/// (反应 6855、11651 本实现对,13618 另一种对);判据见
1442/// `harness/check_product_chirality.py`。
1443///
1444/// 空槽数与新原子数对不上时返回 `None` —— 那时连"依次顶替"都排不出来。
1445fn fill_replaced_slots(slots: &[Option<u32>], after: &[u32]) -> Option<Vec<u32>> {
1446    if slots.iter().all(Option::is_some) {
1447        return Some(slots.iter().flatten().copied().collect());
1448    }
1449    // 顶上来的是产物侧**还没占住槽位**的邻居。
1450    //
1451    // 不能只挑"没有反应物出处"的:接上来的那个可以是别处搬来的、有出处的原子,
1452    // 那样会一个都挑不到而白白放弃重定基。
1453    let taken: BTreeSet<u32> = slots.iter().flatten().copied().collect();
1454    let mut fresh = after.iter().filter(|a| !taken.contains(a));
1455    let filled: Option<Vec<u32>> = slots
1456        .iter()
1457        .map(|s| match s {
1458            Some(x) => Some(*x),
1459            None => fresh.next().copied(),
1460        })
1461        .collect();
1462    let filled = filled?;
1463    // 还剩下新原子没用上 —— 连接变了不止"替换"这么简单,不猜
1464    if fresh.next().is_some() {
1465        return None;
1466    }
1467    Some(filled)
1468}
1469
1470/// 把反应物里未被模板占用的部分接到产物上。
1471/// 反应物里 `a` 与 `b` 之间那根键的方向,换算到"从 `a` 走向 `b`"的参照系。
1472///
1473/// 没有这根键、或它没有方向时返回 `None` 对应的无方向值。
1474/// `~` 那一档要沿用的键级:底物里 `a`–`b` 那根键的键级。没有这根键就是"未指定"。
1475fn inherited_order(mol: &MolBuilder, a: u32, b: u32) -> BondOrder {
1476    mol.neighbors(a)
1477        .find(|&(other, _)| other == b)
1478        .map_or(BondOrder::Unspecified, |(_, bi)| {
1479            mol.bonds()[bi as usize].order
1480        })
1481}
1482
1483fn inherited_direction(mol: &MolBuilder, a: u32, b: u32) -> BondDirection {
1484    let Some((_, bi)) = mol.neighbors(a).find(|&(other, _)| other == b) else {
1485        return BondDirection::None;
1486    };
1487    let src = mol.bonds()[bi as usize];
1488    if src.begin == a {
1489        src.direction
1490    } else {
1491        src.direction.flipped()
1492    }
1493}
1494
1495/// 旁观组分:模板**一个原子都没匹配到**的那些连通分量,原样搬进产物。
1496///
1497/// # 不搬就是丢原子
1498///
1499/// 底物写成 `内酰胺.HCl` 或 `[Na+].[O-]CC(=O)OCC[O-].[K+]` 时,反离子与任何
1500/// 匹配到的原子都不连通,[`carry_over`] 的遍历永远走不到它们。丢掉它们,产物的
1501/// 重原子数就少于底物 —— **引擎自己违反了质量守恒**,而且不报错。
1502///
1503/// 逆合成正是把模板作用到**任意**分子上,盐是常态而非边角,所以这一条不做成开关。
1504///
1505/// # 做法:埋一个种子,剩下的交给同一趟遍历
1506///
1507/// 给每个这样的组分往产物图里放一个种子原子、登记进 `kept`,`carry_over` 随后
1508/// 会从种子出发把整个组分连同它的键搬过来。这样键的排序纪律(按源键下标,不按
1509/// 遍历发现顺序)、手性与顺反的重定基、按连通分量切分、原子映射发号,全都与别处
1510/// 走同一条路径,不必再开一条。
1511///
1512/// # 判据是"有没有被匹配",不是"在不在 `kept` 里"
1513///
1514/// 组分里只要有**一个**原子被模板匹配到,它就归模板管:留下还是删掉是模板的表态,
1515/// 与旁观无关。模板删掉某个原子、挂在它身上的东西跟着失去落脚点,那是另一条约定
1516/// (见本模块的契约),这里不碰。
1517fn seed_spectators(
1518    mol: &MolBuilder,
1519    comp: &[u32],
1520    matched: &[bool],
1521    kept: &mut BTreeMap<u32, u32>,
1522    out: &mut MolBuilder,
1523) {
1524    // 单分量的分子不可能有旁观组分 —— 模板既然匹配上了,那唯一的分量就有匹配原子。
1525    // 绝大多数底物是这一档,先短路掉。
1526    let Some(&n_comp) = comp.iter().max() else {
1527        return;
1528    };
1529    if n_comp == 0 {
1530        return;
1531    }
1532    let n_comp = n_comp as usize + 1;
1533
1534    // 哪些分量里有被匹配到的原子 —— 它们归模板管,不是旁观
1535    let mut has_match = vec![false; n_comp];
1536    for (a, &hit) in matched.iter().enumerate() {
1537        if hit {
1538            has_match[comp[a] as usize] = true;
1539        }
1540    }
1541    // 每个旁观分量埋一个种子:取分量里**下标最小**的原子。遍历的发现顺序不能用,
1542    // 它会让同一个分子因为写法不同而搬出不同的邻居次序。
1543    let mut seeded = vec![false; n_comp];
1544    for (a, &c) in comp.iter().enumerate() {
1545        let c = c as usize;
1546        if has_match[c] || seeded[c] {
1547            continue;
1548        }
1549        seeded[c] = true;
1550        let mut carried = mol.atoms()[a];
1551        carried.atom_map = 0;
1552        let idx = out.add_atom_data(carried);
1553        kept.insert(a as u32, idx);
1554    }
1555}
1556
1557fn carry_over(
1558    mol: &MolBuilder,
1559    matched: &[bool],
1560    template_bonds: &[bool],
1561    kept: &mut BTreeMap<u32, u32>,
1562    out: &mut MolBuilder,
1563) {
1564    // 从已保留的原子出发做一次遍历,沿途把未匹配的原子拉进来
1565    let mut stack: Vec<u32> = kept.keys().copied().collect();
1566    let mut seen: Vec<bool> = vec![false; mol.num_atoms()];
1567    for &a in kept.keys() {
1568        seen[a as usize] = true;
1569    }
1570    // 每条边连它在**反应物里的键下标**一起记,建进产物时按这个下标排 ——
1571    // 搬过来的键因此保持反应物里的相对顺序。
1572    //
1573    // 遍历的发现顺序不能直接用:它取决于从哪个原子起步、栈怎么弹,与反应物
1574    // 自身的顺序无关。手性标记正是相对邻居顺序的,顺序一乱,产物就可能是镜像。
1575    // 模板只钉住一两根键的中心尤其明显 —— 余下的位置全由搬过来的键填,
1576    // 它们的先后直接定构型。
1577    let mut edges: Vec<(u32, u32, u32, BondData)> = Vec::new();
1578
1579    while let Some(a) = stack.pop() {
1580        for (other, bi) in mol.neighbors(a) {
1581            let b = mol.bonds()[bi as usize];
1582            // `other` 被反应物模板匹配到、却不在**本**产物模板里 —— 它归别的
1583            // 产物片段(或压根没有映射号、该被删掉)。遍历到此为止。
1584            //
1585            // 少了这一条,断环键就出错:`[C:1][N:2]>>[C:1].[N:2]` 作用在
1586            // 皮啶上时,从 [C:1] 往外走会绕过整个环、从另一侧撞上那个氮,
1587            // 于是氮被拉进碳那一片,环又接了回去。断键反应在**环上**与在
1588            // 链上的区别正在这里 —— 链上走不回去,环上走得回去。
1589            if matched[other as usize] && !kept.contains_key(&other) {
1590                continue;
1591            }
1592            // 反应物模板**亲自匹配到**的键才归产物模板负责,不搬。
1593            //
1594            // 判据不能是"两端都被匹配就不搬"。子结构匹配只要求模板的每根键
1595            // 在底物里找得到,并不要求底物在这些原子之间没有别的键 —— 模板
1596            // 把一个环写成**开链路径**时,环闭合的那根键两端确实都被匹配了,
1597            // 可模板从没看见它。按"两端都匹配"判,这根键就被当成模板的地盘
1598            // 删掉,环被撕开。撕开之后报出来的是**芳香**错误(原子不在环中
1599            // 却带着芳香标志),病因与症状隔着一层,极难回溯。
1600            //
1601            // rdchiral 抽模板时只沿反应中心走一趟,稠环因此普遍写成路径,
1602            // 这不是边角情形。
1603            //
1604            // 反过来也不能一律搬:模板匹配到、产物侧又不写的键,正是**断键**
1605            // 反应要表达的东西。两条判据缺一不可。
1606            if template_bonds[bi as usize] {
1607                continue;
1608            }
1609            if !seen[other as usize] {
1610                seen[other as usize] = true;
1611                // 搬过来的原子同样清掉映射号,理由同 apply_template
1612                let mut carried = mol.atoms()[other as usize];
1613                carried.atom_map = 0;
1614                let idx = out.add_atom_data(carried);
1615                kept.insert(other, idx);
1616                stack.push(other);
1617            }
1618            edges.push((bi, a, other, b));
1619        }
1620    }
1621    edges.sort_by_key(|&(bi, ..)| bi);
1622
1623    let mut done: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
1624    for (_, a, b, src) in edges {
1625        let key = if a <= b { (a, b) } else { (b, a) };
1626        if !done.insert(key) {
1627            continue;
1628        }
1629        // 新键要沿用**源键的朝向**,而不是遍历到它的方向。
1630        //
1631        // `a` 是遍历的出发端,与 `src.begin` 不一定是同一个原子。两者相反时
1632        // 若按遍历方向建键,朝向就翻了 —— 而朝向是有语义的:
1633        //
1634        // - `direction`(`/` `\`)相对 `begin → end`,翻转即把顺式写成反式
1635        // - 配位键的箭头必须从给电子的一端指向受体
1636        //
1637        // 照源键的 begin/end 映射过去,这些量就能原样抄,不必逐个换参照系;
1638        // 少一次换算就少一处会悄悄写反的地方。
1639        let (Some(&na), Some(&nb)) = (kept.get(&src.begin), kept.get(&src.end)) else {
1640            continue;
1641        };
1642        // 产物模板已经建过这根键 —— 成环模板作用在**已经成环**的底物上就是
1643        // 这样:闭合键不在反应物模板里(反应物侧写的是一条链),却被产物模板
1644        // 明写了出来。模板说了算,再搬一遍就是重复键:不报错,价数却翻倍。
1645        if out.bond_between(na, nb).is_some() {
1646            continue;
1647        }
1648        // 整条键的属性都要跟过来:只抄键级的话,芳香键的标志位会丢,
1649        // 而"键级为 Aromatic 时标志位必须同步"是全局不变量 —— 破了它,
1650        // 写出来的芳香环会变成"大写原子 + 冒号键"这种半吊子形式
1651        let mut nb_data = BondData::new(na, nb, src.order);
1652        nb_data.direction = src.direction;
1653        // 顺反照抄,参照原子先留空 —— 挑参照要看**产物**的连接,而这会儿
1654        // 图还没建完。留空是给 `rebase_bond_stereo` 认的记号,见那里。
1655        nb_data.stereo = src.stereo;
1656        nb_data.stereo_atoms = [BondData::NO_STEREO_ATOM; 2];
1657        nb_data.flags = src.flags;
1658        let _ = out.add_bond_data(nb_data);
1659    }
1660}
1661
1662/// 给搬运过来的双键在**产物**里重挑顺反的参照原子。
1663///
1664/// # 参照必须在产物里还连着这根双键
1665///
1666/// 顺反记在双键上,可它是**相对两个参照原子**说的。反应能动到参照原子的方式
1667/// 有两种,判据都不能只看"那个原子进没进产物":
1668///
1669/// - 参照被**删掉**(模板里没有映射号)
1670/// - 参照活着,却被挪到了**别的产物片段**去
1671///
1672/// 后一种最阴:`kept` 里查得到,于是参照被换成一个属于另一个分子的下标。切分
1673/// 之后那个下标在本分子里要么越界(几何静默丢失),要么正好落在某个真邻居上
1674/// (几何**静默变错**)。两种都不报错。
1675///
1676/// # 顶替者占的是被顶替者的**位置**
1677///
1678/// 挑替代不能随便找同端的另一个取代基 —— 那个在双键的另一侧,顺反的含义跟着
1679/// 反。取代的几何含义是新取代基占据被替换者原来的空间位置,所以按**槽位**挑:
1680/// 把该端的取代基按反应物里的顺序排开,走掉的留空,再拿产物侧多出来的邻居依次
1681/// 顶上。顶替者与被顶替者同侧,顺反因此原样成立,一次都不用翻。
1682///
1683/// # 为什么放在这里而不是搬运的时候
1684///
1685/// 挑参照要看产物侧该端还连着谁,而搬运时图还没建完。`carry_over` 因此只抄
1686/// `stereo`、把参照留成哨兵值,由本函数认这个记号再填。模板自己建的键不带
1687/// `stereo`,所以不会被误认。
1688fn rebase_bond_stereo(mol: &MolBuilder, kept: &BTreeMap<u32, u32>, out: &mut MolBuilder) {
1689    for src in mol.bonds() {
1690        if src.stereo == BondStereo::None
1691            || src.stereo_atoms[0] == BondData::NO_STEREO_ATOM
1692            || src.stereo_atoms[1] == BondData::NO_STEREO_ATOM
1693        {
1694            continue;
1695        }
1696        let (Some(&pb), Some(&pe)) = (kept.get(&src.begin), kept.get(&src.end)) else {
1697            continue;
1698        };
1699        let Some(bi) = out.bond_between(pb, pe) else {
1700            continue;
1701        };
1702        // 只认 `carry_over` 留下的记号 —— 模板建的键不走这一路
1703        let cur = out.bonds()[bi as usize];
1704        if cur.stereo == BondStereo::None || cur.stereo_atoms[0] != BondData::NO_STEREO_ATOM {
1705            continue;
1706        }
1707
1708        let mut refs = [BondData::NO_STEREO_ATOM; 2];
1709        // 换到"另一侧那个取代基"上的次数。奇数次就要把顺反翻过来。
1710        let mut flips = 0usize;
1711        for (i, (end, other, p_end, p_other)) in
1712            [(src.begin, src.end, pb, pe), (src.end, src.begin, pe, pb)]
1713                .into_iter()
1714                .enumerate()
1715        {
1716            let want = src.stereo_atoms[i];
1717            // 产物侧该端的取代基(不含双键另一端)
1718            let p_subs: Vec<u32> = out
1719                .neighbors(p_end)
1720                .map(|(o, _)| o)
1721                .filter(|&o| o != p_other)
1722                .collect();
1723            // 参照原封不动地还连着 —— 直接用
1724            if let Some(&p) = kept.get(&want) {
1725                if p_subs.contains(&p) {
1726                    refs[i] = p;
1727                    continue;
1728                }
1729            }
1730            // 走掉了(被删,或挪去了别的片段)—— 找占了它那个槽位的
1731            let subs: Vec<u32> = mol
1732                .neighbors(end)
1733                .map(|(o, _)| o)
1734                .filter(|&o| o != other)
1735                .collect();
1736            let Some(pos) = subs.iter().position(|&o| o == want) else {
1737                break;
1738            };
1739            let slots: Vec<Option<u32>> = subs
1740                .iter()
1741                .map(|o| kept.get(o).copied().filter(|p| p_subs.contains(p)))
1742                .collect();
1743            let Some(filled) = fill_replaced_slots(&slots, &p_subs) else {
1744                // **没人顶这个槽位** —— 走掉的位置由隐式氢补上,而隐式氢没有
1745                // 下标,做不了参照原子。这时候要**改参照到该端另一个取代基,
1746                // 并把顺反翻一次**:它在双键的另一侧。
1747                //
1748                // 先前这里直接放弃,整根双键的顺反跟着作废。实测大语料上
1749                // 硝基烯烃 `[N+](/C(=C/Ar)C)([O-])=O` 被 `[C:1][N:2]>>[C:1].[N:2]`
1750                // 打掉硝基之后,交付的是 `CC=Cc1ccccc1` —— **E/Z 整个没了**,
1751                // 而双键还在、两端各自还有取代基,构型依旧成立。
1752                //
1753                // 谁对不是推的:把底物嵌成真实三维构象、把离去的氮**原地**换成
1754                // 氢再读回构型,三个分子五个 seed 都给出 `C/C=C\Ar` 这一类
1755                // (判据先自校准过:同一条路读底物本身,五个 seed 都还原输入)。
1756                let Some(&alt) = subs.iter().find(|&&o| o != want) else {
1757                    // 该端只有走掉的那一个取代基 —— 换成两个隐式氢,
1758                    // 这根双键**真的**没有构型可言了,作废是对的
1759                    break;
1760                };
1761                let Some(&p_alt) = kept.get(&alt) else {
1762                    break;
1763                };
1764                if !p_subs.contains(&p_alt) {
1765                    break;
1766                }
1767                refs[i] = p_alt;
1768                flips += 1;
1769                continue;
1770            };
1771            refs[i] = filled[pos];
1772        }
1773
1774        if let Some(mut b) = out.bond_mut(bi) {
1775            if refs[0] == BondData::NO_STEREO_ATOM || refs[1] == BondData::NO_STEREO_ATOM {
1776                // 挑不出参照就作废 —— 留一个指向别人的下标比没有更糟
1777                b.set_stereo(BondStereo::None);
1778            } else if flips % 2 == 0 {
1779                b.set_stereo_atoms(refs);
1780            } else {
1781                match src.stereo {
1782                    BondStereo::Cis => {
1783                        b.set_stereo(BondStereo::Trans);
1784                        b.set_stereo_atoms(refs);
1785                    }
1786                    BondStereo::Trans => {
1787                        b.set_stereo(BondStereo::Cis);
1788                        b.set_stereo_atoms(refs);
1789                    }
1790                    // Z/E 是按 **CIP 优先级**定的,与记录的参照原子无关 ——
1791                    // 换参照不该翻它;而取代基换掉之后 CIP 排序本身也可能变,
1792                    // 那要重新定优先级,不是翻个号能解决的。不猜,作废。
1793                    _ => b.set_stereo(BondStereo::None),
1794                }
1795            }
1796        }
1797    }
1798}
1799
1800/// 把产物模板里写死的属性盖到原子上。
1801///
1802/// 模板里没写的属性**保持继承来的值** —— 这正是"分子其余部分自动跟着走"
1803/// 的原子级体现:`[C:1]` 只说"这里是个碳",电荷、同位素都不动。
1804///
1805/// 模板里的映射号**不**写进产物:它连的是两个模板,不是分子的属性。留在产物
1806/// 里的话,写出的 SMILES 会带上 `[CH2:1]` 这种本不该有的标注,而且下一次拿这个
1807/// 产物当底物时,那些号会跟新模板的号撞上。
1808///
1809/// 要的是底物层面的原子对应关系时,用 [`run_reactants`] 的 `atom_mapping`
1810/// 参数 —— 那套号是运行时另发的,见 [`stamp_atom_maps`]。
1811fn apply_template(
1812    mut base: AtomData,
1813    expr: &AtomExpr,
1814    degree_kept: bool,
1815    plan: ChiralityPlan,
1816) -> AtomData {
1817    // 自由基电子数是**派生量**,不是原子的固有属性:它由具体的 Kekulé 结构、
1818    // 电荷与氢数一起定下来,而模板恰恰会把这三样都改掉。继承过来就是一个陈旧值。
1819    //
1820    // 陈旧在哪不显眼:净化里 kekulize 排在自由基重算**之前**(自由基数要等键级
1821    // 定下来才算得出),于是那个陈旧值会被 kekulize 当真。一个三价碳
1822    // (`[C]`,带一个自由基)被模板改写成芳香碳之后,kekulize 认为它不能再要
1823    // 双键,整个芳香环就配不出 Kekulé 结构 —— 报的错落在环上某个无辜的原子身上,
1824    // 离根因很远。
1825    //
1826    // 清成 0 之后,产物走的路与"把这个分子写出来再读回去"完全一致:
1827    // 新解析的分子这个字段本来就是 0,由 `assign_radicals` 在 kekulize 之后重算。
1828
1829    base.num_radical_electrons = 0;
1830
1831    // 元素一旦被模板改掉,继承来的一切都失去意义 —— `[OH:2]` 变成 `[Cl:2]`
1832    // 时若把氧的那个氢留下,得到的是 ClH,直接超价。电荷、同位素同理。
1833    let element_changed = template_element(expr).is_some_and(|z| z != base.atomic_num);
1834
1835    // 氢数只在"元素没变**且**连接没变"时才继承。断了一条键的原子要补氢:
1836    // `[C:1][N:2]>>[C:1].[N:2]` 把丙氨酸的 C—N 断开,那个碳应当从 CH 变成
1837    // CH2。照抄氢数的话会得到一个凭空少了个氢的自由基。
1838    if element_changed || !degree_kept {
1839        base.num_explicit_hs = 0;
1840        base.num_implicit_hs = 0;
1841        base.flags.remove(omgkit_core::AtomFlags::NO_IMPLICIT);
1842    }
1843    if element_changed {
1844        base.formal_charge = 0;
1845        base.isotope = 0;
1846        base.chiral_tag = ChiralTag::Unspecified;
1847    }
1848    // 继承来的构型,`apply_expr` 有可能把它盖掉,所以先存下来
1849    let inherited = base.chiral_tag;
1850    apply_expr(&mut base, expr);
1851    base.chiral_tag = match plan {
1852        ChiralityPlan::Inherit | ChiralityPlan::Set => base.chiral_tag,
1853        ChiralityPlan::Drop => ChiralTag::Unspecified,
1854        ChiralityPlan::Retain => inherited,
1855        ChiralityPlan::Invert => inherited.inverted(),
1856    };
1857    base.atom_map = 0;
1858    base
1859}
1860
1861/// 产物模板给这个原子指定的元素。写成析取式(`[C,N:1]`)时说不出是哪个,
1862/// 返回 `None`。
1863fn template_element(expr: &AtomExpr) -> Option<u8> {
1864    match expr {
1865        AtomExpr::Prim(AtomPrim::Element { z, .. }) => Some(*z),
1866        AtomExpr::And(parts) => parts.iter().find_map(template_element),
1867        _ => None,
1868    }
1869}
1870
1871fn apply_expr(a: &mut AtomData, expr: &AtomExpr) {
1872    match expr {
1873        AtomExpr::Prim(p) => apply_prim(a, p),
1874        AtomExpr::And(parts) => {
1875            for p in parts {
1876                apply_expr(a, p);
1877            }
1878        }
1879        // 析取与否定在产物侧没有确定含义 —— 写 `[C,N:1]` 说不出该建哪个,
1880        // 所以一律忽略,保留继承来的值
1881        AtomExpr::Or(_) | AtomExpr::Not(_) => {}
1882    }
1883}
1884
1885fn apply_prim(a: &mut AtomData, p: &AtomPrim) {
1886    match p {
1887        AtomPrim::Element { z, aromatic } => {
1888            a.atomic_num = *z;
1889            if let Some(arom) = aromatic {
1890                a.flags.set(omgkit_core::AtomFlags::AROMATIC, *arom);
1891            }
1892        }
1893        AtomPrim::Charge(c) => a.formal_charge = i8::try_from(*c).unwrap_or(0),
1894        AtomPrim::Isotope(i) => a.isotope = *i,
1895        AtomPrim::TotalHs(n) => {
1896            a.num_explicit_hs = u8::try_from(*n).unwrap_or(0);
1897            a.num_implicit_hs = 0;
1898            a.flags.insert(omgkit_core::AtomFlags::NO_IMPLICIT);
1899        }
1900        AtomPrim::Chirality(t) => a.chiral_tag = *t,
1901        // 其余基元是**筛选**条件,不是构建指令:`[C;R1:1]` 里的 R1 说的是
1902        // "只匹配环上的碳",不是"把产物做成环"
1903        _ => {}
1904    }
1905}
1906
1907/// 这条键表达式写的是 `<-` 吗。
1908///
1909/// 查询侧用两个基元区分配位键的朝向,端点则按书写顺序存;产物侧靠端点顺序
1910/// 表达朝向。两种表示之间要换算,靠的就是这个判断。
1911fn is_dative_reversed(expr: &BondExpr) -> bool {
1912    match expr {
1913        BondExpr::Prim(BondPrim::DativeReversed) => true,
1914        BondExpr::And(parts) => parts.iter().any(is_dative_reversed),
1915        // 析取与否定说不出确定的朝向,按写法原样建
1916        _ => false,
1917    }
1918}
1919
1920/// 产物模板里的键表达式指定的方向(`/` `\`)。没写方向时返回 `None` 对应的值。
1921///
1922/// 方向与键级是**两件事**:`/` 既说"这是单键",也说"取代基在双键的哪一侧"。
1923/// [`product_bond_from`] 只取前者,后者要靠这里取,否则模板里写的几何会被
1924/// 悄悄丢掉 —— 产物从确定的顺反退化成未指定。
1925fn bond_direction_from(expr: &BondExpr) -> BondDirection {
1926    match expr {
1927        BondExpr::Prim(BondPrim::UpRight) => BondDirection::UpRight,
1928        BondExpr::Prim(BondPrim::DownRight) => BondDirection::DownRight,
1929        // 合取式里任一支写了方向就算数(`/&!@` 这类)
1930        BondExpr::And(parts) => parts
1931            .iter()
1932            .map(bond_direction_from)
1933            .find(|d| *d != BondDirection::None)
1934            .unwrap_or(BondDirection::None),
1935        // 析取与否定说不出确定的方向:`/,\` 是"两侧都行",不是某一侧
1936        _ => BondDirection::None,
1937    }
1938}
1939
1940/// 产物模板里的一根键该建成什么 —— 三种情形,不是一种。
1941#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1942enum ProductBond {
1943    /// 模板写死了键级
1944    Fixed(BondOrder),
1945    /// **省略了键符号。** 两端都是芳香原子就建芳香键,否则单键。
1946    FollowAromaticity,
1947    /// `~` —— 沿用底物那根键;底物没有这根键时是"未指定"。
1948    Inherit,
1949}
1950
1951/// 产物模板里的键表达式该建成什么键。
1952///
1953/// # 省略键符号**不是**单键
1954///
1955/// SMARTS 里省略键符号的默认值是析取 `单键 或 芳香键`([`BondExpr::default_bond`]),
1956/// 而这正是产物模板最常见的写法。先前这里把析取整个退回单键,于是
1957/// `>>[c:1]1[c:2][c:3][c:4][n:5]1` 建出来的是吡咯**烷** —— 五个芳香原子之间连
1958/// 五根单键,而这样的产物净化得过、不报任何错,调用方拿到一个结构良好的错分子。
1959///
1960/// 参照实现的规矩在 `ReactionRunner.cpp:391-406`:析取默认值时看两端原子的
1961/// 芳香性 —— 都芳香就建芳香键,否则单键;`~` 另算,标记成"沿用底物"。这里照办。
1962///
1963/// 其余析取/否定说不出确定的键级,取第一个说得出的子式;一个都没有就按省略处理。
1964fn product_bond_from(expr: &BondExpr) -> ProductBond {
1965    match expr {
1966        BondExpr::Prim(BondPrim::Any) => ProductBond::Inherit,
1967        BondExpr::Prim(p) => ProductBond::Fixed(match p {
1968            BondPrim::Double => BondOrder::Double,
1969            BondPrim::Triple => BondOrder::Triple,
1970            BondPrim::Quadruple => BondOrder::Quadruple,
1971            BondPrim::Aromatic => BondOrder::Aromatic,
1972            BondPrim::Dative | BondPrim::DativeReversed => BondOrder::Dative,
1973            _ => BondOrder::Single,
1974        }),
1975        BondExpr::And(parts) => parts
1976            .iter()
1977            .map(product_bond_from)
1978            .find(|o| !matches!(o, ProductBond::Fixed(BondOrder::Single)))
1979            .unwrap_or(ProductBond::Fixed(BondOrder::Single)),
1980        BondExpr::Or(_) | BondExpr::Not(_) => {
1981            if *expr == BondExpr::default_bond() {
1982                return ProductBond::FollowAromaticity;
1983            }
1984            let parts = match expr {
1985                BondExpr::Or(parts) => parts.as_slice(),
1986                _ => &[],
1987            };
1988            parts
1989                .iter()
1990                .map(product_bond_from)
1991                .find(|o| matches!(o, ProductBond::Fixed(_)))
1992                .unwrap_or(ProductBond::FollowAromaticity)
1993        }
1994    }
1995}
1996
1997#[cfg(test)]
1998mod tests {
1999    use super::*;
2000
2001    /// 两侧模板的邻居次序怎么算宇称,以及什么时候该放弃。
2002    ///
2003    /// 端到端那条判据在 `tests/reaction.rs`
2004    /// (`both_sides_written_are_compared_in_a_common_neighbour_order`);
2005    /// 这里守的是边界:对应关系不唯一时必须给 `None`,而不是随便算一个宇称
2006    /// 出来 —— 算出来的那个会被当真,把构型悄悄写反。
2007    #[test]
2008    fn template_order_parity_gives_up_when_the_correspondence_is_not_unique() {
2009        let n = |v: &[u16]| -> Vec<Option<u16>> { v.iter().map(|&x| Some(x)).collect() };
2010
2011        // 次序相同 —— 偶
2012        assert_eq!(
2013            template_order_is_odd(&n(&[2, 3, 4]), &n(&[2, 3, 4])),
2014            Some(false)
2015        );
2016        // 对调一对 —— 奇
2017        assert_eq!(
2018            template_order_is_odd(&n(&[2, 3, 4]), &n(&[4, 3, 2])),
2019            Some(true)
2020        );
2021        // 轮换一圈是两次对调 —— 偶
2022        assert_eq!(
2023            template_order_is_odd(&n(&[2, 3, 4]), &n(&[3, 4, 2])),
2024            Some(false)
2025        );
2026
2027        // 每侧各有一个对方没有的邻居:互相顶替,对应唯一
2028        let mut react = n(&[2, 3, 4]);
2029        react[0] = None;
2030        let mut prod = n(&[2, 3, 4]);
2031        prod[2] = None;
2032        assert!(
2033            template_order_is_odd(&react, &prod).is_some(),
2034            "各有一个对不上时该顶替得起来"
2035        );
2036
2037        // 一侧两个对不上 —— 两种配对宇称相反,不能挑一个
2038        assert_eq!(
2039            template_order_is_odd(&n(&[2, 3, 4]), &n(&[5, 6, 4])),
2040            None,
2041            "产物侧有两个邻居在反应物侧找不到,对应关系不唯一"
2042        );
2043        // 度数不足 3 谈不上四面体手性
2044        assert_eq!(template_order_is_odd(&n(&[2, 3]), &n(&[2, 3])), None);
2045        // 两侧差出一个以上
2046        assert_eq!(
2047            template_order_is_odd(&n(&[2, 3, 4]), &n(&[2, 3, 4, 5, 6])),
2048            None
2049        );
2050    }
2051}