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