Skip to main content

molframe_core/bond/
model.rs

1//! Compact connectivity storage and lazy graph traversal.
2//!
3//! Bonds live as sorted edge columns. Graph algorithms can request a cached
4//! CSR view, built in O(atoms + bonds) time and storage, without imposing an
5//! adjacency allocation on structures whose analyses never traverse bonds.
6
7use crate::index::{AtomIndex, BondIndex};
8use std::ops::Range;
9use std::sync::{Arc, OnceLock};
10
11/// The chemical order assigned to a bond.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub enum BondOrder {
14    /// One shared electron pair.
15    Single,
16    /// Two shared electron pairs.
17    Double,
18    /// Three shared electron pairs.
19    Triple,
20    /// Four shared electron pairs.
21    Quadruple,
22    /// Delocalised aromatic connectivity.
23    Aromatic,
24    /// A linkage between consecutive polymer components.
25    Polymeric,
26    /// Connectivity is known but its order is not.
27    #[default]
28    Unknown,
29}
30
31/// Where a bond assignment came from.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub enum BondProvenance {
34    /// An explicit record in the input structure.
35    File,
36    /// The chemical component dictionary.
37    ChemicalComponentDictionary,
38    /// An explicitly requested distance calculation.
39    InferredDistance,
40    /// An explicit caller edit.
41    User,
42}
43
44/// One bond before it is packed into columns.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct BondRecord {
47    /// First atom endpoint.
48    pub atom_a: AtomIndex,
49    /// Second atom endpoint.
50    pub atom_b: AtomIndex,
51    /// Chemical order.
52    pub order: BondOrder,
53    /// Source of this assignment.
54    pub provenance: BondProvenance,
55}
56
57/// A compact, immutable edge table.
58#[derive(Clone, Debug)]
59pub struct BondTable {
60    atom_a: Arc<Vec<AtomIndex>>,
61    atom_b: Arc<Vec<AtomIndex>>,
62    order: Arc<Vec<BondOrder>>,
63    provenance: Arc<Vec<BondProvenance>>,
64    adjacency: Arc<OnceLock<BondAdjacency>>,
65    available: bool,
66}
67
68impl Default for BondTable {
69    fn default() -> Self {
70        Self {
71            atom_a: Arc::new(Vec::new()),
72            atom_b: Arc::new(Vec::new()),
73            order: Arc::new(Vec::new()),
74            provenance: Arc::new(Vec::new()),
75            adjacency: Arc::new(OnceLock::new()),
76            available: false,
77        }
78    }
79}
80
81impl BondTable {
82    /// Number of unique undirected edges.
83    #[must_use]
84    pub fn len(&self) -> usize {
85        self.atom_a.len()
86    }
87
88    /// Whether no connectivity is available.
89    #[must_use]
90    pub fn is_empty(&self) -> bool {
91        self.atom_a.is_empty()
92    }
93
94    /// Whether connectivity was resolved, including a known empty graph.
95    #[must_use]
96    pub const fn is_available(&self) -> bool {
97        self.available
98    }
99
100    /// Returns one edge by position.
101    #[must_use]
102    pub fn get(&self, bond: BondIndex) -> Option<BondRecord> {
103        let position = bond.as_usize();
104        Some(BondRecord {
105            atom_a: *self.atom_a.get(position)?,
106            atom_b: *self.atom_b.get(position)?,
107            order: *self.order.get(position)?,
108            provenance: *self.provenance.get(position)?,
109        })
110    }
111
112    /// Walks records in stable endpoint order.
113    #[must_use]
114    pub fn iter(&self) -> impl ExactSizeIterator<Item = BondRecord> + '_ {
115        self.atom_a
116            .iter()
117            .zip(self.atom_b.iter())
118            .zip(self.order.iter())
119            .zip(self.provenance.iter())
120            .map(|(((atom_a, atom_b), order), provenance)| BondRecord {
121                atom_a: *atom_a,
122                atom_b: *atom_b,
123                order: *order,
124                provenance: *provenance,
125            })
126    }
127
128    /// Returns a cached compact adjacency index for graph traversal.
129    #[must_use]
130    pub fn adjacency(&self, atom_count: u32) -> &BondAdjacency {
131        self.adjacency
132            .get_or_init(|| BondAdjacency::build(self, atom_count))
133    }
134}
135
136/// Deterministic bond-table construction with endpoint deduplication.
137///
138/// Records accumulate in insertion order and are sorted and deduplicated once
139/// at `finish`. A tree keyed on the endpoint pair would allocate a node per
140/// bond, which at billions of bonds dominates the build; an amortised push plus
141/// a single sort produces the same ordering for a fraction of the work.
142#[derive(Debug, Default)]
143pub struct BondTableBuilder {
144    records: Vec<BondRecord>,
145}
146
147impl BondTableBuilder {
148    /// Creates an empty builder.
149    #[must_use]
150    pub const fn new() -> Self {
151        Self {
152            records: Vec::new(),
153        }
154    }
155
156    /// Adds an undirected edge. The first assignment wins on duplicates.
157    ///
158    /// Self-edges are retained so structure validation can report them rather
159    /// than silently changing the caller's data.
160    pub fn push(&mut self, mut record: BondRecord) {
161        if record.atom_b < record.atom_a {
162            std::mem::swap(&mut record.atom_a, &mut record.atom_b);
163        }
164        self.records.push(record);
165    }
166
167    /// Packs records into immutable columns.
168    #[must_use]
169    pub fn finish(self) -> BondTable {
170        self.finish_with_availability(true)
171    }
172
173    /// Packs records while preserving whether the complete graph is known.
174    ///
175    /// A merge can retain explicit edges from one input while marking the
176    /// combined graph unavailable because another input carried no connectivity.
177    #[must_use]
178    pub fn finish_with_availability(mut self, available: bool) -> BondTable {
179        // The sort is stable and `dedup_by_key` keeps the first of each run, so
180        // the earliest record for a pair wins exactly as an insert-if-absent
181        // would, and the output stays in ascending endpoint order.
182        self.records
183            .sort_by_key(|record| (record.atom_a.get(), record.atom_b.get()));
184        self.records
185            .dedup_by_key(|record| (record.atom_a.get(), record.atom_b.get()));
186
187        let mut atom_a = Vec::with_capacity(self.records.len());
188        let mut atom_b = Vec::with_capacity(self.records.len());
189        let mut order = Vec::with_capacity(self.records.len());
190        let mut provenance = Vec::with_capacity(self.records.len());
191        for record in self.records {
192            atom_a.push(record.atom_a);
193            atom_b.push(record.atom_b);
194            order.push(record.order);
195            provenance.push(record.provenance);
196        }
197        BondTable {
198            atom_a: Arc::new(atom_a),
199            atom_b: Arc::new(atom_b),
200            order: Arc::new(order),
201            provenance: Arc::new(provenance),
202            adjacency: Arc::new(OnceLock::new()),
203            available,
204        }
205    }
206}
207
208/// A compressed sparse row view of an undirected bond graph.
209#[derive(Clone, Debug, Default)]
210pub struct BondAdjacency {
211    offsets: Vec<u32>,
212    neighbours: Vec<AtomIndex>,
213}
214
215impl BondAdjacency {
216    fn build(table: &BondTable, atom_count: u32) -> Self {
217        let mut offsets = vec![0u32; atom_count as usize + 1];
218        for record in table.iter() {
219            if record.atom_a.get() < atom_count && record.atom_b.get() < atom_count {
220                offsets[record.atom_a.as_usize() + 1] += 1;
221                offsets[record.atom_b.as_usize() + 1] += 1;
222            }
223        }
224        for position in 1..offsets.len() {
225            offsets[position] += offsets[position - 1];
226        }
227        let neighbour_count = match offsets.last() {
228            Some(count) => *count,
229            None => 0,
230        };
231        let mut neighbours = vec![AtomIndex::new(0); neighbour_count as usize];
232        // The prefix sums are spent as write cursors and restored afterwards,
233        // so the traversal needs one buffer rather than a second copy of the
234        // offsets held only to keep the sums while the cursors advance.
235        for record in table.iter() {
236            if record.atom_a.get() >= atom_count || record.atom_b.get() >= atom_count {
237                continue;
238            }
239            insert(&mut neighbours, &mut offsets, record.atom_a, record.atom_b);
240            insert(&mut neighbours, &mut offsets, record.atom_b, record.atom_a);
241        }
242        // Each cursor advanced past its own start by exactly the degree it
243        // filled, so walking backwards restores every prefix sum.
244        for position in (1..offsets.len()).rev() {
245            offsets[position] = offsets[position - 1];
246        }
247        offsets[0] = 0;
248        for atom in 0..atom_count as usize {
249            let range = offsets[atom] as usize..offsets[atom + 1] as usize;
250            neighbours[range].sort_unstable();
251        }
252        Self {
253            offsets,
254            neighbours,
255        }
256    }
257
258    /// Directly connected atoms in stable ordinal order.
259    #[must_use]
260    pub fn neighbours(&self, atom: AtomIndex) -> &[AtomIndex] {
261        let Some(range) = self.range(atom) else {
262            return &[];
263        };
264        &self.neighbours[range]
265    }
266
267    fn range(&self, atom: AtomIndex) -> Option<Range<usize>> {
268        let next = atom.as_usize().checked_add(1)?;
269        Some(*self.offsets.get(atom.as_usize())? as usize..*self.offsets.get(next)? as usize)
270    }
271}
272
273fn insert(neighbours: &mut [AtomIndex], cursor: &mut [u32], atom: AtomIndex, value: AtomIndex) {
274    let position = cursor[atom.as_usize()] as usize;
275    if let Some(slot) = neighbours.get_mut(position) {
276        *slot = value;
277        cursor[atom.as_usize()] += 1;
278    }
279}
280
281#[cfg(test)]
282#[path = "model_tests.rs"]
283mod tests;