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 crate::optional::OptionalSymbol;
9use std::ops::Range;
10use std::sync::{Arc, OnceLock};
11
12/// The chemical order assigned to a bond.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub enum BondOrder {
15    /// One shared electron pair.
16    Single,
17    /// Two shared electron pairs.
18    Double,
19    /// Three shared electron pairs.
20    Triple,
21    /// Four shared electron pairs.
22    Quadruple,
23    /// Delocalised aromatic connectivity.
24    Aromatic,
25    /// A linkage between consecutive polymer components.
26    Polymeric,
27    /// Connectivity is known but its order is not.
28    #[default]
29    Unknown,
30}
31
32/// Where a bond assignment came from.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub enum BondProvenance {
35    /// An explicit record in the input structure.
36    File,
37    /// The chemical component dictionary.
38    ChemicalComponentDictionary,
39    /// An explicitly requested distance calculation.
40    InferredDistance,
41    /// An explicit caller edit.
42    User,
43}
44
45/// One bond before it is packed into columns.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub struct BondRecord {
48    /// First atom endpoint.
49    pub atom_a: AtomIndex,
50    /// Second atom endpoint.
51    pub atom_b: AtomIndex,
52    /// Chemical order.
53    pub order: BondOrder,
54    /// Source of this assignment.
55    pub provenance: BondProvenance,
56    /// The connection's own classification, as the source recorded it, interned
57    /// in the dictionary that owns this record.
58    ///
59    /// This is the deposited connectivity kind — `covale`, `disulf`, `metalc`,
60    /// `modres`, or a source-defined refinement — and it is independent of
61    /// [`order`](Self::order) and of [`provenance`](Self::provenance). A metal
62    /// coordination bond and a covalent one can share an order and still mean
63    /// different chemistry, so a consumer that must not treat coordination as an
64    /// ordinary covalent edge reads this field rather than inferring from the
65    /// atom names.
66    ///
67    /// An identifier means something only against the structure whose dictionary
68    /// issued it. Copying one into a structure with an independent dictionary is
69    /// invalid; the merge and transfer paths remap it, and
70    /// [`NONE`](OptionalSymbol::NONE) means the source recorded no classification
71    /// at all, which is not the same as any particular class.
72    pub kind: OptionalSymbol,
73}
74
75impl BondRecord {
76    /// Whether the source explicitly classifies this edge as metal coordination.
77    /// An absent or unknown class is not evidence of coordination; elements,
78    /// bond order and provenance never substitute for the deposited class.
79    #[must_use]
80    pub fn is_metal_coordination(&self, dictionary: &crate::symbol::Interner) -> bool {
81        self.kind
82            .get()
83            .and_then(|symbol| dictionary.resolve(symbol))
84            .is_some_and(|kind| kind.eq_ignore_ascii_case("metalc"))
85    }
86
87    /// A record whose source recorded no connection classification.
88    ///
89    /// Most producers infer connectivity from chemistry or from distances and
90    /// have no deposited `conn_type_id` to carry; this states that absence rather
91    /// than picking a class.
92    #[must_use]
93    pub const fn unclassified(
94        atom_a: AtomIndex,
95        atom_b: AtomIndex,
96        order: BondOrder,
97        provenance: BondProvenance,
98    ) -> Self {
99        Self {
100            atom_a,
101            atom_b,
102            order,
103            provenance,
104            kind: OptionalSymbol::NONE,
105        }
106    }
107}
108
109/// A compact, immutable edge table.
110#[derive(Clone, Debug)]
111pub struct BondTable {
112    atom_a: Arc<Vec<AtomIndex>>,
113    atom_b: Arc<Vec<AtomIndex>>,
114    order: Arc<Vec<BondOrder>>,
115    provenance: Arc<Vec<BondProvenance>>,
116    kind: Arc<Vec<OptionalSymbol>>,
117    adjacency: Arc<OnceLock<BondAdjacency>>,
118    available: bool,
119}
120
121impl Default for BondTable {
122    fn default() -> Self {
123        Self {
124            atom_a: Arc::new(Vec::new()),
125            atom_b: Arc::new(Vec::new()),
126            order: Arc::new(Vec::new()),
127            provenance: Arc::new(Vec::new()),
128            kind: Arc::new(Vec::new()),
129            adjacency: Arc::new(OnceLock::new()),
130            available: false,
131        }
132    }
133}
134
135impl BondTable {
136    /// Number of unique undirected edges.
137    #[must_use]
138    pub fn len(&self) -> usize {
139        self.atom_a.len()
140    }
141
142    /// Whether no connectivity is available.
143    #[must_use]
144    pub fn is_empty(&self) -> bool {
145        self.atom_a.is_empty()
146    }
147
148    /// Whether connectivity was resolved, including a known empty graph.
149    #[must_use]
150    pub const fn is_available(&self) -> bool {
151        self.available
152    }
153
154    /// Returns one edge by position.
155    #[must_use]
156    pub fn get(&self, bond: BondIndex) -> Option<BondRecord> {
157        let position = bond.as_usize();
158        Some(BondRecord {
159            atom_a: *self.atom_a.get(position)?,
160            atom_b: *self.atom_b.get(position)?,
161            order: *self.order.get(position)?,
162            provenance: *self.provenance.get(position)?,
163            kind: *self.kind.get(position)?,
164        })
165    }
166
167    /// Walks records in stable endpoint order.
168    #[must_use]
169    pub fn iter(&self) -> impl ExactSizeIterator<Item = BondRecord> + '_ {
170        self.atom_a
171            .iter()
172            .zip(self.atom_b.iter())
173            .zip(self.order.iter())
174            .zip(self.provenance.iter())
175            .zip(self.kind.iter())
176            .map(
177                |((((atom_a, atom_b), order), provenance), kind)| BondRecord {
178                    atom_a: *atom_a,
179                    atom_b: *atom_b,
180                    order: *order,
181                    provenance: *provenance,
182                    kind: *kind,
183                },
184            )
185    }
186
187    /// The interned connection classification of each edge, position for
188    /// position with [`iter`](Self::iter).
189    ///
190    /// The identifiers belong to the dictionary of the structure that owns this
191    /// table; resolving one requires that structure.
192    #[must_use]
193    pub fn kinds(&self) -> &[OptionalSymbol] {
194        &self.kind
195    }
196
197    /// Returns a cached compact adjacency index for graph traversal.
198    #[must_use]
199    pub fn adjacency(&self, atom_count: u32) -> &BondAdjacency {
200        self.adjacency
201            .get_or_init(|| BondAdjacency::build(self, atom_count))
202    }
203}
204
205/// Deterministic bond-table construction with endpoint deduplication.
206///
207/// Records accumulate in insertion order and are sorted and deduplicated once
208/// at `finish`. A tree keyed on the endpoint pair would allocate a node per
209/// bond, which at billions of bonds dominates the build; an amortised push plus
210/// a single sort produces the same ordering for a fraction of the work.
211#[derive(Debug, Default)]
212pub struct BondTableBuilder {
213    records: Vec<BondRecord>,
214}
215
216impl BondTableBuilder {
217    /// Creates an empty builder.
218    #[must_use]
219    pub const fn new() -> Self {
220        Self {
221            records: Vec::new(),
222        }
223    }
224
225    /// Adds an undirected edge. The first assignment wins on duplicates.
226    ///
227    /// Self-edges are retained so structure validation can report them rather
228    /// than silently changing the caller's data.
229    pub fn push(&mut self, mut record: BondRecord) {
230        if record.atom_b < record.atom_a {
231            std::mem::swap(&mut record.atom_a, &mut record.atom_b);
232        }
233        self.records.push(record);
234    }
235
236    /// Packs records into immutable columns.
237    #[must_use]
238    pub fn finish(self) -> BondTable {
239        self.finish_with_availability(true)
240    }
241
242    /// Packs records while preserving whether the complete graph is known.
243    ///
244    /// A merge can retain explicit edges from one input while marking the
245    /// combined graph unavailable because another input carried no connectivity.
246    #[must_use]
247    pub fn finish_with_availability(mut self, available: bool) -> BondTable {
248        // The sort is stable and `dedup_by_key` keeps the first of each run, so
249        // the earliest record for a pair wins exactly as an insert-if-absent
250        // would, and the output stays in ascending endpoint order.
251        self.records
252            .sort_by_key(|record| (record.atom_a.get(), record.atom_b.get()));
253        self.records
254            .dedup_by_key(|record| (record.atom_a.get(), record.atom_b.get()));
255
256        let mut atom_a = Vec::with_capacity(self.records.len());
257        let mut atom_b = Vec::with_capacity(self.records.len());
258        let mut order = Vec::with_capacity(self.records.len());
259        let mut provenance = Vec::with_capacity(self.records.len());
260        let mut kind = Vec::with_capacity(self.records.len());
261        for record in self.records {
262            atom_a.push(record.atom_a);
263            atom_b.push(record.atom_b);
264            order.push(record.order);
265            provenance.push(record.provenance);
266            kind.push(record.kind);
267        }
268        BondTable {
269            atom_a: Arc::new(atom_a),
270            atom_b: Arc::new(atom_b),
271            order: Arc::new(order),
272            provenance: Arc::new(provenance),
273            kind: Arc::new(kind),
274            adjacency: Arc::new(OnceLock::new()),
275            available,
276        }
277    }
278}
279
280/// A compressed sparse row view of an undirected bond graph.
281#[derive(Clone, Debug, Default)]
282pub struct BondAdjacency {
283    offsets: Vec<u32>,
284    neighbours: Vec<AtomIndex>,
285}
286
287impl BondAdjacency {
288    fn build(table: &BondTable, atom_count: u32) -> Self {
289        let mut offsets = vec![0u32; atom_count as usize + 1];
290        for record in table.iter() {
291            if record.atom_a.get() < atom_count && record.atom_b.get() < atom_count {
292                offsets[record.atom_a.as_usize() + 1] += 1;
293                offsets[record.atom_b.as_usize() + 1] += 1;
294            }
295        }
296        for position in 1..offsets.len() {
297            offsets[position] += offsets[position - 1];
298        }
299        let neighbour_count = match offsets.last() {
300            Some(count) => *count,
301            None => 0,
302        };
303        let mut neighbours = vec![AtomIndex::new(0); neighbour_count as usize];
304        // The prefix sums are spent as write cursors and restored afterwards,
305        // so the traversal needs one buffer rather than a second copy of the
306        // offsets held only to keep the sums while the cursors advance.
307        for record in table.iter() {
308            if record.atom_a.get() >= atom_count || record.atom_b.get() >= atom_count {
309                continue;
310            }
311            insert(&mut neighbours, &mut offsets, record.atom_a, record.atom_b);
312            insert(&mut neighbours, &mut offsets, record.atom_b, record.atom_a);
313        }
314        // Each cursor advanced past its own start by exactly the degree it
315        // filled, so walking backwards restores every prefix sum.
316        for position in (1..offsets.len()).rev() {
317            offsets[position] = offsets[position - 1];
318        }
319        offsets[0] = 0;
320        for atom in 0..atom_count as usize {
321            let range = offsets[atom] as usize..offsets[atom + 1] as usize;
322            neighbours[range].sort_unstable();
323        }
324        Self {
325            offsets,
326            neighbours,
327        }
328    }
329
330    /// Directly connected atoms in stable ordinal order.
331    #[must_use]
332    pub fn neighbours(&self, atom: AtomIndex) -> &[AtomIndex] {
333        let Some(range) = self.range(atom) else {
334            return &[];
335        };
336        &self.neighbours[range]
337    }
338
339    fn range(&self, atom: AtomIndex) -> Option<Range<usize>> {
340        let next = atom.as_usize().checked_add(1)?;
341        Some(*self.offsets.get(atom.as_usize())? as usize..*self.offsets.get(next)? as usize)
342    }
343}
344
345fn insert(neighbours: &mut [AtomIndex], cursor: &mut [u32], atom: AtomIndex, value: AtomIndex) {
346    let position = cursor[atom.as_usize()] as usize;
347    if let Some(slot) = neighbours.get_mut(position) {
348        *slot = value;
349        cursor[atom.as_usize()] += 1;
350    }
351}
352
353#[cfg(test)]
354#[path = "model_tests.rs"]
355mod tests;