Skip to main content

omgkit_core/
builder.rs

1//! 可变的单分子构造器。
2//!
3//! 架构上刻意与 [`MolBatch`](crate::MolBatch) 分开:编辑操作(解析建图、
4//! 反应产物构建)需要随意增删原子和键,而列式布局要为此付出高昂代价。
5//! 于是分成两段 —— `MolBuilder` 负责建,`freeze` 之后进入不可变的列式批,
6//! 所有算法都跑在后者上。
7//!
8//! # 邻接索引
9//!
10//! `MolBuilder` 自带一份**始终有效**的邻接索引,[`neighbors`](MolBuilder::neighbors)
11//! 与 [`degree`](MolBuilder::degree) 都是 O(度数)。没有它的话,"取某原子的
12//! 邻居"只能扫全部键,而化学算法几乎每一步都在做这件事,整体就退化成
13//! O(原子数 × 键数)。
14//!
15//! ## 为什么是半边链表,不是 CSR
16//!
17//! [`MolBatch`](crate::MolBatch) 用 CSR,因为它不可变、只被扫描。`MolBuilder` 是**增量构建**
18//! 的:CSR 每加一条键都要重排整个邻接数组,解析一个 E 条键的分子就变成
19//! O(E·(V+E))。半边链表加边是 O(1),代价是遍历时跳指针 —— 在编辑期的
20//! 规模下无所谓,真正吃吞吐的批量算法跑在 `MolBatch` 上。
21//!
22//! 这个分工是架构层面的:**`MolBuilder` 为编辑优化,`MolBatch` 为扫描优化。**
23//!
24//! ## 索引不会失效
25//!
26//! 索引在 [`add_bond_data`](MolBuilder::add_bond_data) 里同步维护,不是缓存,
27//! 没有脏标记,也不需要谁记得去刷新。为此 [`bond_mut`](MolBuilder::bond_mut)
28//! 返回的 [`BondMut`] **不暴露端点** —— 改端点就是改拓扑,只能走建边接口。
29//!
30//! ```
31//! use omgkit_core::{MolBuilder, BondOrder};
32//!
33//! // 乙醇 CCO
34//! let mut b = MolBuilder::new();
35//! let c0 = b.add_atom(6);
36//! let c1 = b.add_atom(6);
37//! let o  = b.add_atom(8);
38//! b.add_bond(c0, c1, BondOrder::Single).unwrap();
39//! b.add_bond(c1, o,  BondOrder::Single).unwrap();
40//! assert_eq!(b.num_atoms(), 3);
41//! assert_eq!(b.num_bonds(), 2);
42//! ```
43
44use crate::error::{Error, Result};
45use crate::types::{
46    AtomFlags, BondDirection, BondFlags, BondOrder, BondStereo, ChiralTag, Hybridization,
47};
48
49/// 单个原子的可变数据。
50///
51/// 字段与 [`MolBatch`](crate::MolBatch) 的列一一对应 —— 增加字段时两处必须同步。
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct AtomData {
54    /// 原子序数。0 表示 SMILES 通配原子 `*`。
55    pub atomic_num: u8,
56    /// 形式电荷
57    pub formal_charge: i8,
58    /// 同位素质量数。0 表示未指定(即天然丰度)。
59    pub isotope: u16,
60    /// 方括号中显式书写的氢数。仅当 [`AtomFlags::NO_IMPLICIT`] 置位时有意义。
61    pub num_explicit_hs: u8,
62    /// 隐式氢数。由 L2 的价键计算填充,解析阶段恒为 0。
63    pub num_implicit_hs: u8,
64    /// 自由基电子数。由 L2 第 6 步 `FINDRADICALS` 填充,在那之前恒为 0。
65    ///
66    /// 隐式氢推断会读它,所以两者的先后顺序有实际后果 ——
67    /// 净化管线里第 6 步排在第 3 步**之后**,故第 3 步看到的必然是 0。
68    pub num_radical_electrons: u8,
69    /// 反应原子映射号(SMILES 中的 `:n`)。0 表示无映射。
70    pub atom_map: u16,
71    /// 立体标记的几何类别
72    pub chiral_tag: ChiralTag,
73    /// 立体标记的类内排列序号,与 [`chiral_tag`](Self::chiral_tag) 配套。
74    ///
75    /// 0 表示未指定。四面体的两种排列由 `chiral_tag` 自身表达,此处恒为 0 ——
76    /// 两处都记就有了两个可以互相矛盾的真相来源。
77    ///
78    /// # 相对**邻居的存储顺序**
79    ///
80    /// 序号的含义依赖"配体按什么顺序排列",而解析会重排邻居(环闭合键统一
81    /// 追加到末尾)—— 不说清楚"相对什么顺序",这个字段就没有意义。
82    ///
83    /// | 类别 | 多面体有几个顶点 | 本字段相对什么顺序 |
84    /// |---|---|---|
85    /// | [`ChiralTag::SquarePlanar`] | 4 | 邻居的**存储顺序**(解析时已归一) |
86    /// | [`ChiralTag::TrigonalBipyramidal`] | 5 | 同上 |
87    /// | [`ChiralTag::Octahedral`] | 6 | 同上 |
88    /// | 以上三类但**顶点比邻居多两个及以上** | — | 书写时的字面值;写出时整个丢掉 |
89    /// | [`ChiralTag::Allene`] (`@AL`) | 4(来自累积双键**两端**) | 四个配体的**存储顺序**(解析时已归一) |
90    ///
91    /// 丙二烯那一行的"配体"不是这个原子自己的邻居 —— 中心只有两个邻居,
92    /// 四个配体是两端端原子上的取代基。取不到四个(标记没落在丙二烯中心上、
93    /// 某一端两个配体相同)时序号归 0,写出侧整个丢掉。
94    ///
95    /// 顶点比邻居**多一个**时照样归一:方括号里的氢、或者一个空的配位位置,
96    /// 也占一个顶点而不在邻居序列里,补一个占位的进去即可 —— 它排在存储序的
97    /// **最前**,书写序里则落在"自身位置"。补法在 `omgkit-io` 那一侧
98    /// (`smiles::coordination_ligands`),解析与写出共用同一份。
99    ///
100    /// **多两个及以上**就不归一了:那几个顶点在 SMILES 里全落在同一处、彼此
101    /// 分不开,换算出来的序号不唯一。那时写出侧**整个丢掉**这个标记:丢掉是
102    /// 老实的,瞎写一个序号是撒谎。
103    ///
104    /// 换算见 [`crate::polyhedron::renumber`];那三张表是从 RDKit 2025.09.2
105    /// 穷举量出来的(72 / 2400 / 21600 种写法,零反例),不是照着规范条文写的。
106    ///
107    pub stereo_perm: u8,
108    /// 杂化状态。由净化第 9 步填充,在那之前为 `Unspecified`。
109    pub hybridization: Hybridization,
110    /// 标志位
111    pub flags: AtomFlags,
112}
113
114impl AtomData {
115    /// 构造一个只指定了元素的原子,其余字段取默认值。
116    #[must_use]
117    pub fn new(atomic_num: u8) -> Self {
118        Self {
119            atomic_num,
120            formal_charge: 0,
121            isotope: 0,
122            num_explicit_hs: 0,
123            num_implicit_hs: 0,
124            num_radical_electrons: 0,
125            atom_map: 0,
126            chiral_tag: ChiralTag::Unspecified,
127            stereo_perm: 0,
128            hybridization: Hybridization::Unspecified,
129            flags: AtomFlags::NONE,
130        }
131    }
132}
133
134impl Default for AtomData {
135    fn default() -> Self {
136        Self::new(0)
137    }
138}
139
140/// 单条键的可变数据。端点为**分子内局部**原子下标。
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub struct BondData {
143    /// 起点原子(局部下标)
144    pub begin: u32,
145    /// 终点原子(局部下标)
146    pub end: u32,
147    /// 键级
148    pub order: BondOrder,
149    /// SMILES 方向键 `/` `\`
150    pub direction: BondDirection,
151    /// 双键立体。由 omgkit-io 的 stereo::perceive_bond_stereo 从 direction 感知,
152    /// 与 [`stereo_atoms`](Self::stereo_atoms) 配套。
153    pub stereo: BondStereo,
154    /// 顺/反的**参照原子**:`[begin 侧一个, end 侧一个]`。
155    ///
156    /// 只有 `stereo` 不是 [`BondStereo::None`] 时有意义,那时两个下标都合法。
157    /// 无参照时是 [`BondData::NO_STEREO_ATOM`]。
158    ///
159    /// # 为什么必须存
160    ///
161    /// "顺"与"反"离开参照就没有意义 —— 一根双键两端各有两个取代基,说
162    /// "同侧"总得回答"谁和谁同侧"。
163    ///
164    /// 这也是它与 [`BondData::direction`] 的分工:方向是**写法**,依附于
165    /// 某根单键,那根键被删掉信息就没了;顺反是双键**自己**的属性,只要
166    /// 两个参照原子还在就一直成立。图编辑之后要重新写出方向键,靠的是这一对。
167    pub stereo_atoms: [u32; 2],
168    /// 标志位
169    pub flags: BondFlags,
170}
171
172impl BondData {
173    /// [`BondData::stereo_atoms`] 里表示"没有参照原子"的值。
174    pub const NO_STEREO_ATOM: u32 = u32::MAX;
175
176    /// 构造一条指定端点与键级的键。
177    #[must_use]
178    pub fn new(begin: u32, end: u32, order: BondOrder) -> Self {
179        Self {
180            begin,
181            end,
182            order,
183            direction: BondDirection::None,
184            stereo: BondStereo::None,
185            stereo_atoms: [Self::NO_STEREO_ATOM; 2],
186            flags: BondFlags::NONE,
187        }
188    }
189
190    /// 本键对端点 `atom` 的**价贡献**。
191    ///
192    /// 配位键的贡献是**不对称**的:对起点(给体)算 0,对终点(受体)算 1。
193    /// 这与直觉相反,也与 [`BondOrder::as_double`] 不同 —— 后者对配位键
194    /// 一律算 1。搞混会让隐式氢推断在有机金属分子上系统性出错。
195    ///
196    /// `atom` 不是本键端点时返回 0。
197    #[must_use]
198    pub fn valence_contribution_to(&self, atom: u32) -> f32 {
199        if atom != self.begin && atom != self.end {
200            return 0.0;
201        }
202        if self.order == BondOrder::Dative && atom != self.end {
203            return 0.0; // 给体不计
204        }
205        self.order.as_double()
206    }
207
208    /// 给定一端,返回另一端。若 `from` 不是本键端点则返回 `None`。
209    #[must_use]
210    pub fn other_end(&self, from: u32) -> Option<u32> {
211        if from == self.begin {
212            Some(self.end)
213        } else if from == self.end {
214            Some(self.begin)
215        } else {
216            None
217        }
218    }
219}
220
221/// 半边编号的空标记。
222const NO_HALF: u32 = u32::MAX;
223
224/// 可变的单分子构造器。
225///
226/// 自带邻接索引;见[模块文档](self)。
227#[derive(Debug, Clone, Default)]
228pub struct MolBuilder {
229    atoms: Vec<AtomData>,
230    bonds: Vec<BondData>,
231    name: Option<String>,
232
233    // -- 邻接索引:每原子一条半边链表 --
234    //
235    // 键 `bi` 拆成两条半边:`2*bi` 挂在 `begin` 上,`2*bi+1` 挂在 `end` 上。
236    // 由半边编号 `h` 可反推:键号 `h >> 1`,`h & 1 == 0` 表示自己是 begin 侧,
237    // 于是邻居就是另一端。这样不必为邻居再存一份原子号。
238    /// 每原子的链首半边,`NO_HALF` 表示孤立原子
239    first_half: Vec<u32>,
240    /// 每原子的链尾半边,用于 O(1) 尾插 —— 尾插保证遍历顺序 = 键的插入顺序
241    last_half: Vec<u32>,
242    /// 每条半边的后继,`NO_HALF` 表示链尾。下标即半边编号
243    next_half: Vec<u32>,
244    /// 每原子的度(不含隐式氢)
245    degree: Vec<u32>,
246}
247
248impl MolBuilder {
249    /// 空分子。
250    #[must_use]
251    pub fn new() -> Self {
252        Self::default()
253    }
254
255    /// 预分配容量的空分子。解析器已知大致规模时用这个避免反复扩容。
256    #[must_use]
257    pub fn with_capacity(n_atoms: usize, n_bonds: usize) -> Self {
258        Self {
259            atoms: Vec::with_capacity(n_atoms),
260            bonds: Vec::with_capacity(n_bonds),
261            name: None,
262            first_half: Vec::with_capacity(n_atoms),
263            last_half: Vec::with_capacity(n_atoms),
264            next_half: Vec::with_capacity(n_bonds * 2),
265            degree: Vec::with_capacity(n_atoms),
266        }
267    }
268
269    /// 追加一个只指定元素的原子,返回其局部下标。
270    pub fn add_atom(&mut self, atomic_num: u8) -> u32 {
271        self.add_atom_data(AtomData::new(atomic_num))
272    }
273
274    /// 追加一个完整指定的原子,返回其局部下标。
275    pub fn add_atom_data(&mut self, atom: AtomData) -> u32 {
276        let idx = self.atoms.len() as u32;
277        self.atoms.push(atom);
278        self.first_half.push(NO_HALF);
279        self.last_half.push(NO_HALF);
280        self.degree.push(0);
281        idx
282    }
283
284    /// 追加一条键,返回其局部下标。
285    ///
286    /// # Errors
287    /// 端点越界、或两端相同(自环)时返回错误。分子图中不存在自环,
288    /// 让它静默通过会在 CSR 构建时产生难以追查的度数异常。
289    pub fn add_bond(&mut self, begin: u32, end: u32, order: BondOrder) -> Result<u32> {
290        self.add_bond_data(BondData::new(begin, end, order))
291    }
292
293    /// 追加一条完整指定的键,返回其局部下标。
294    ///
295    /// 同时把两条半边挂进端点的邻接链表,O(1)。
296    ///
297    /// # Errors
298    /// 同 [`add_bond`](Self::add_bond)。
299    ///
300    /// # Panics
301    /// 键数超过 `u32::MAX / 2` 时 —— 半边编号会与哨兵冲突。这需要 32 GB
302    /// 仅存键表,实际不可达,但宁可响亮地停下也不要静默算错。
303    pub fn add_bond_data(&mut self, bond: BondData) -> Result<u32> {
304        let n = self.atoms.len() as u32;
305        if bond.begin >= n || bond.end >= n {
306            return Err(Error::AtomIndexOutOfRange {
307                index: bond.begin.max(bond.end),
308                num_atoms: n,
309            });
310        }
311        if bond.begin == bond.end {
312            return Err(Error::SelfLoop { atom: bond.begin });
313        }
314        assert!(
315            self.bonds.len() < (u32::MAX / 2) as usize,
316            "键数超出半边编号能表示的范围"
317        );
318        let idx = self.bonds.len() as u32;
319        self.bonds.push(bond);
320        self.link_half(bond.begin, idx * 2);
321        self.link_half(bond.end, idx * 2 + 1);
322        Ok(idx)
323    }
324
325    /// 交换一条键的两个端点。
326    ///
327    /// 端点顺序对**配位键**有语义(`begin` 是给电子的一端),这个接口就是为它
328    /// 准备的。其余键型的端点顺序只是书写痕迹,交换它没有意义。
329    ///
330    /// # 为什么是整体重建索引
331    ///
332    /// 半边链表是单向的:把两条半边在两个原子的链表之间对调,要各自找到前驱
333    /// 再改指针,还要顾及链首链尾 —— 几十行且容易出错。而整体重建只有几行,
334    /// 显然正确,并且保持"邻居顺序 = 键的插入顺序"这条不变量(重建按键号
335    /// 递增走,每个原子的相对顺序不变)。
336    ///
337    /// 代价是 O(原子数 + 键数)。这个接口的调用场景是净化第 2 步,
338    /// 实测 8839 条语料里只触发 2 次 —— 为它做精细的指针手术不划算。
339    ///
340    /// # Errors
341    /// 键下标越界时返回 [`Error::BondIndexOutOfRange`]。
342    pub fn swap_bond_ends(&mut self, bond: u32) -> Result<()> {
343        let num_bonds = self.bonds.len() as u32;
344        let b = self
345            .bonds
346            .get_mut(bond as usize)
347            .ok_or(Error::BondIndexOutOfRange {
348                index: bond,
349                num_bonds,
350            })?;
351        std::mem::swap(&mut b.begin, &mut b.end);
352        self.rebuild_index();
353        Ok(())
354    }
355
356    /// 按当前的键表重建邻接索引。
357    fn rebuild_index(&mut self) {
358        let n = self.atoms.len();
359        self.first_half.clear();
360        self.first_half.resize(n, NO_HALF);
361        self.last_half.clear();
362        self.last_half.resize(n, NO_HALF);
363        self.degree.clear();
364        self.degree.resize(n, 0);
365        self.next_half.clear();
366
367        for i in 0..self.bonds.len() {
368            let (begin, end) = (self.bonds[i].begin, self.bonds[i].end);
369            self.link_half(begin, (i * 2) as u32);
370            self.link_half(end, (i * 2 + 1) as u32);
371        }
372    }
373
374    /// 把半边 `half` 尾插到 `atom` 的链表上。
375    ///
376    /// 必须按半边编号递增调用 —— `next_half` 的下标就是半边编号。
377    fn link_half(&mut self, atom: u32, half: u32) {
378        debug_assert_eq!(
379            self.next_half.len() as u32,
380            half,
381            "半边必须按编号顺序追加,否则 next_half 的下标语义就断了"
382        );
383        self.next_half.push(NO_HALF);
384
385        let a = atom as usize;
386        let tail = self.last_half[a];
387        if tail == NO_HALF {
388            self.first_half[a] = half;
389        } else {
390            self.next_half[tail as usize] = half;
391        }
392        self.last_half[a] = half;
393        self.degree[a] += 1;
394    }
395
396    /// 遍历某原子的邻居,产出 `(邻居原子下标, 键下标)`。O(度数)。
397    ///
398    /// 顺序即**键的插入顺序**,与 [`MolView::neighbors`](crate::MolView::neighbors)
399    /// 一致 —— 手性语义依赖于这个顺序,两处必须给出同样的序列。
400    ///
401    /// 原子下标越界时产出空序列。
402    #[must_use]
403    pub fn neighbors(&self, atom: u32) -> Neighbors<'_> {
404        let head = self
405            .first_half
406            .get(atom as usize)
407            .copied()
408            .unwrap_or(NO_HALF);
409        Neighbors {
410            mol: self,
411            half: head,
412        }
413    }
414
415    /// 原子的度(不含隐式氢)。O(1);越界返回 0。
416    #[must_use]
417    pub fn degree(&self, atom: u32) -> usize {
418        self.degree.get(atom as usize).copied().unwrap_or(0) as usize
419    }
420
421    /// 连接 `a` 与 `b` 的键下标。O(min 度数);不相邻时返回 `None`。
422    #[must_use]
423    pub fn bond_between(&self, a: u32, b: u32) -> Option<u32> {
424        // 从度数小的一端出发
425        let from = if self.degree(a) <= self.degree(b) {
426            a
427        } else {
428            b
429        };
430        let to = if from == a { b } else { a };
431        self.neighbors(from)
432            .find(|&(nbr, _)| nbr == to)
433            .map(|(_, bi)| bi)
434    }
435
436    /// 原子数
437    #[must_use]
438    pub fn num_atoms(&self) -> usize {
439        self.atoms.len()
440    }
441
442    /// 键数
443    #[must_use]
444    pub fn num_bonds(&self) -> usize {
445        self.bonds.len()
446    }
447
448    /// 只读访问全部原子
449    #[must_use]
450    pub fn atoms(&self) -> &[AtomData] {
451        &self.atoms
452    }
453
454    /// 只读访问全部键
455    #[must_use]
456    pub fn bonds(&self) -> &[BondData] {
457        &self.bonds
458    }
459
460    /// 可变访问单个原子
461    pub fn atom_mut(&mut self, idx: u32) -> Option<&mut AtomData> {
462        self.atoms.get_mut(idx as usize)
463    }
464
465    /// 可变访问单条键的**属性**。
466    ///
467    /// 返回的 [`BondMut`] 刻意不暴露端点 —— 端点即拓扑,而邻接索引是随建边
468    /// 增量维护的。若允许就地改端点,索引会在无人察觉的情况下失效,而这类
469    /// bug 只会在很久以后以"某个原子少了个邻居"的形式冒出来。
470    pub fn bond_mut(&mut self, idx: u32) -> Option<BondMut<'_>> {
471        self.bonds
472            .get_mut(idx as usize)
473            .map(|bond| BondMut { bond })
474    }
475
476    /// 分子名(SDF 的标题行、SMILES 后跟的名字)
477    #[must_use]
478    pub fn name(&self) -> Option<&str> {
479        self.name.as_deref()
480    }
481
482    /// 设置分子名
483    pub fn set_name(&mut self, name: impl Into<String>) {
484        self.name = Some(name.into());
485    }
486
487    /// 重新计算邻接索引并与当前索引比对。仅供测试使用。
488    ///
489    /// 索引是增量维护的,任何维护逻辑的错误都会静默地表现为"图连错了"。
490    /// 这个自检把它变成显式失败。
491    #[doc(hidden)]
492    #[must_use]
493    pub fn adjacency_index_is_consistent(&self) -> bool {
494        for a in 0..self.atoms.len() as u32 {
495            let expected: Vec<(u32, u32)> = self
496                .bonds
497                .iter()
498                .enumerate()
499                .filter_map(|(bi, b)| b.other_end(a).map(|o| (o, bi as u32)))
500                .collect();
501            let actual: Vec<(u32, u32)> = self.neighbors(a).collect();
502            if expected != actual || self.degree(a) != expected.len() {
503                return false;
504            }
505        }
506        true
507    }
508}
509
510/// [`MolBuilder::neighbors`] 的迭代器,沿半边链表前进。
511#[derive(Debug, Clone)]
512pub struct Neighbors<'a> {
513    mol: &'a MolBuilder,
514    half: u32,
515}
516
517impl Iterator for Neighbors<'_> {
518    /// `(邻居原子下标, 键下标)`
519    type Item = (u32, u32);
520
521    fn next(&mut self) -> Option<Self::Item> {
522        if self.half == NO_HALF {
523            return None;
524        }
525        let h = self.half;
526        self.half = self.mol.next_half[h as usize];
527
528        let bi = h >> 1;
529        let bond = self.mol.bonds[bi as usize];
530        // h 为偶 ⇒ 挂在 begin 上 ⇒ 邻居是 end
531        let nbr = if h & 1 == 0 { bond.end } else { bond.begin };
532        Some((nbr, bi))
533    }
534}
535
536/// 键属性的可变句柄,由 [`MolBuilder::bond_mut`] 取得。
537///
538/// **不提供修改端点的途径**,理由见 [`MolBuilder::bond_mut`]。
539#[derive(Debug)]
540pub struct BondMut<'a> {
541    bond: &'a mut BondData,
542}
543
544impl BondMut<'_> {
545    /// 读回当前键的完整数据
546    #[must_use]
547    pub fn get(&self) -> BondData {
548        *self.bond
549    }
550
551    /// 设置键级
552    pub fn set_order(&mut self, order: BondOrder) {
553        self.bond.order = order;
554    }
555
556    /// 设置方向键标记(`/` `\`)
557    pub fn set_direction(&mut self, direction: BondDirection) {
558        self.bond.direction = direction;
559    }
560
561    /// 设置双键立体
562    pub fn set_stereo(&mut self, stereo: BondStereo) {
563        self.bond.stereo = stereo;
564    }
565
566    /// 设置顺反的参照原子。
567    ///
568    /// 与 [`set_stereo`](Self::set_stereo) 配套 —— 顺反离开参照没有意义,
569    /// 两者要一起写。
570    pub fn set_stereo_atoms(&mut self, atoms: [u32; 2]) {
571        self.bond.stereo_atoms = atoms;
572    }
573
574    /// 可变访问标志位
575    pub fn flags_mut(&mut self) -> &mut BondFlags {
576        &mut self.bond.flags
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    #[test]
585    fn build_ethanol() {
586        let mut b = MolBuilder::new();
587        let c0 = b.add_atom(6);
588        let c1 = b.add_atom(6);
589        let o = b.add_atom(8);
590        b.add_bond(c0, c1, BondOrder::Single).unwrap();
591        b.add_bond(c1, o, BondOrder::Single).unwrap();
592
593        assert_eq!(b.num_atoms(), 3);
594        assert_eq!(b.num_bonds(), 2);
595        assert_eq!(b.atoms()[2].atomic_num, 8);
596    }
597
598    #[test]
599    fn rejects_out_of_range_endpoint() {
600        let mut b = MolBuilder::new();
601        b.add_atom(6);
602        let err = b.add_bond(0, 5, BondOrder::Single).unwrap_err();
603        assert!(matches!(
604            err,
605            Error::AtomIndexOutOfRange {
606                index: 5,
607                num_atoms: 1
608            }
609        ));
610    }
611
612    #[test]
613    fn rejects_self_loop() {
614        let mut b = MolBuilder::new();
615        b.add_atom(6);
616        let err = b.add_bond(0, 0, BondOrder::Single).unwrap_err();
617        assert!(matches!(err, Error::SelfLoop { atom: 0 }));
618    }
619
620    #[test]
621    fn bond_other_end() {
622        let bond = BondData::new(3, 7, BondOrder::Double);
623        assert_eq!(bond.other_end(3), Some(7));
624        assert_eq!(bond.other_end(7), Some(3));
625        assert_eq!(bond.other_end(5), None);
626    }
627}
628
629#[cfg(test)]
630mod adjacency_tests {
631    use super::*;
632
633    /// 异丁烷 CC(C)C:中心碳连着三个甲基
634    fn isobutane() -> MolBuilder {
635        let mut m = MolBuilder::new();
636        for _ in 0..4 {
637            m.add_atom(6);
638        }
639        m.add_bond(0, 1, BondOrder::Single).unwrap();
640        m.add_bond(1, 2, BondOrder::Single).unwrap();
641        m.add_bond(1, 3, BondOrder::Single).unwrap();
642        m
643    }
644
645    #[test]
646    fn neighbors_and_degree() {
647        let m = isobutane();
648        assert_eq!(
649            m.neighbors(1).collect::<Vec<_>>(),
650            vec![(0, 0), (2, 1), (3, 2)]
651        );
652        assert_eq!(m.neighbors(0).collect::<Vec<_>>(), vec![(1, 0)]);
653        assert_eq!(m.degree(1), 3);
654        assert_eq!(m.degree(0), 1);
655    }
656
657    #[test]
658    fn isolated_atom_has_no_neighbors() {
659        let mut m = MolBuilder::new();
660        m.add_atom(10); // Ne
661        assert_eq!(m.neighbors(0).count(), 0);
662        assert_eq!(m.degree(0), 0);
663    }
664
665    #[test]
666    fn out_of_range_atom_is_empty_not_panic() {
667        let m = isobutane();
668        assert_eq!(m.neighbors(99).count(), 0);
669        assert_eq!(m.degree(99), 0);
670        assert_eq!(m.bond_between(99, 0), None);
671    }
672
673    /// 邻居顺序必须是**键的插入顺序** —— 手性判定依赖于此。
674    /// 这条与 `MolView::neighbors` 的同名不变量是一对,两边不能各说各话。
675    #[test]
676    fn neighbor_order_is_bond_insertion_order() {
677        let mut m = MolBuilder::new();
678        for _ in 0..4 {
679            m.add_atom(6);
680        }
681        // 故意让中心原子在键里时而作 begin、时而作 end
682        m.add_bond(3, 0, BondOrder::Single).unwrap();
683        m.add_bond(0, 1, BondOrder::Single).unwrap();
684        m.add_bond(2, 0, BondOrder::Single).unwrap();
685
686        assert_eq!(
687            m.neighbors(0).map(|(a, _)| a).collect::<Vec<_>>(),
688            vec![3, 1, 2],
689            "无论中心原子在哪一端,顺序都应是键的插入顺序"
690        );
691        assert_eq!(
692            m.neighbors(0).map(|(_, b)| b).collect::<Vec<_>>(),
693            vec![0, 1, 2]
694        );
695    }
696
697    #[test]
698    fn bond_between_finds_edges_from_either_side() {
699        let m = isobutane();
700        assert_eq!(m.bond_between(1, 0), Some(0));
701        assert_eq!(m.bond_between(0, 1), Some(0));
702        assert_eq!(m.bond_between(1, 3), Some(2));
703        assert_eq!(m.bond_between(0, 2), None, "0 与 2 不相邻");
704    }
705
706    /// 索引是增量维护的,拒边之后必须原样不动 —— 否则一次失败的
707    /// `add_bond` 会悄悄污染整张图。
708    #[test]
709    fn rejected_bond_leaves_index_untouched() {
710        let mut m = isobutane();
711        assert!(m.add_bond(1, 1, BondOrder::Single).is_err());
712        assert!(m.add_bond(0, 99, BondOrder::Single).is_err());
713        assert_eq!(m.degree(1), 3);
714        assert_eq!(m.num_bonds(), 3);
715        assert!(m.adjacency_index_is_consistent());
716    }
717
718    /// 建图过程中的**每一步**索引都必须自洽,不只是最后一步。
719    #[test]
720    fn index_stays_consistent_through_incremental_build() {
721        let mut m = MolBuilder::new();
722        assert!(m.adjacency_index_is_consistent());
723        for i in 0..12u32 {
724            m.add_atom(6);
725            assert!(m.adjacency_index_is_consistent(), "加原子 {i} 后失配");
726            if i > 0 {
727                m.add_bond(i - 1, i, BondOrder::Single).unwrap();
728                assert!(m.adjacency_index_is_consistent(), "加键 {i} 后失配");
729            }
730        }
731        // 再补几条成环的键,制造非线性拓扑
732        m.add_bond(0, 11, BondOrder::Single).unwrap();
733        m.add_bond(3, 8, BondOrder::Single).unwrap();
734        assert!(m.adjacency_index_is_consistent());
735    }
736
737    /// 克隆出来的分子必须带着一份同样有效的索引。
738    #[test]
739    fn clone_carries_a_valid_index() {
740        let m = isobutane().clone();
741        assert!(m.adjacency_index_is_consistent());
742        assert_eq!(m.degree(1), 3);
743    }
744
745    /// 改键级、改标志都不该动到拓扑。
746    #[test]
747    fn property_edits_do_not_disturb_topology() {
748        let mut m = isobutane();
749        let mut b = m.bond_mut(1).unwrap();
750        b.set_order(BondOrder::Double);
751        b.flags_mut().insert(BondFlags::AROMATIC);
752        b.set_direction(BondDirection::UpRight);
753        assert!(m.adjacency_index_is_consistent());
754        assert_eq!(m.bonds()[1].order, BondOrder::Double);
755        assert_eq!(m.bonds()[1].direction, BondDirection::UpRight);
756    }
757}
758
759#[cfg(test)]
760mod valence_contrib_tests {
761    use super::*;
762
763    /// 配位键的价贡献不对称:起点(给体)算 0,终点(受体)算 1。
764    #[test]
765    fn dative_contribution_is_asymmetric() {
766        let d = BondData::new(3, 7, BondOrder::Dative);
767        assert_eq!(d.valence_contribution_to(3), 0.0, "给体不计价");
768        assert_eq!(d.valence_contribution_to(7), 1.0, "受体计 1");
769        assert_eq!(d.valence_contribution_to(9), 0.0, "非端点");
770    }
771
772    #[test]
773    fn normal_bonds_are_symmetric() {
774        for (order, v) in [
775            (BondOrder::Single, 1.0),
776            (BondOrder::Double, 2.0),
777            (BondOrder::Triple, 3.0),
778            (BondOrder::Aromatic, 1.5),
779        ] {
780            let b = BondData::new(1, 2, order);
781            assert_eq!(b.valence_contribution_to(1), v);
782            assert_eq!(b.valence_contribution_to(2), v);
783        }
784    }
785}