Skip to main content

omgkit_core/
builder.rs

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