Skip to main content

molframe_core/structure/
validate.rs

1//! The invariants a structure must satisfy.
2//!
3//! These are checked when a structure is built and whenever an edit is
4//! committed, so a violation is caught at the one place that could have caused
5//! it rather than surfacing later as an implausible number. Every violation is
6//! reported rather than only the first, because a structure with a broken
7//! hierarchy usually has more than one thing wrong with it.
8//!
9//! A violation here is a defect in whatever built the structure. No input file,
10//! however malformed, should be able to produce one — a reader that cannot make
11//! sense of a file emits a diagnostic and declines, rather than building
12//! something inconsistent.
13
14use super::data::{CoordinateStore, StructureData};
15use crate::Structure;
16use crate::coords::CoordinateBlock;
17use crate::diagnostic::{Code, Diagnostic, Diagnostics};
18use crate::index::ResidueIndex;
19
20/// Checks every invariant, returning what was violated.
21///
22/// An empty result means the structure is sound.
23#[must_use]
24pub fn validate(data: &StructureData) -> Vec<Diagnostic> {
25    let mut findings = Diagnostics::new();
26    let atoms = atoms_in_chunks(data);
27
28    check_child_ranges(data, &mut findings);
29    check_atom_coverage(data, atoms, &mut findings);
30    check_entity_references(data, &mut findings);
31    check_bonds(data, atoms, &mut findings);
32    check_annotations(data, &mut findings);
33    check_coordinate_counts(data, atoms, &mut findings);
34    check_coordinates(data, &mut findings);
35    check_occupancies(data, &mut findings);
36
37    findings.finish()
38}
39
40/// Checks the invariants that a coordinate-only edit can change.
41pub(crate) fn validate_coordinate_edit(data: &StructureData) -> Vec<Diagnostic> {
42    let mut findings = Diagnostics::new();
43    let atoms = atoms_in_chunks(data);
44    check_coordinate_counts(data, atoms, &mut findings);
45    check_coordinates(data, &mut findings);
46    findings.finish()
47}
48
49/// Returns the atom extent declared by the structure's chunk sequence.
50///
51/// An empty chunk collection represents an empty atom extent.
52fn atoms_in_chunks(data: &StructureData) -> u32 {
53    data.chunks.last().map_or(0, |chunk| chunk.atoms().end)
54}
55
56/// Verifies that all annotation columns remain aligned with the atom table.
57///
58/// Every annotation must contain exactly one row for each atom.
59fn check_annotations(data: &StructureData, findings: &mut Diagnostics) {
60    let atoms = data.atom_count();
61
62    for (name, column) in data.annotations.iter() {
63        if column.len() == atoms {
64            continue;
65        }
66
67        findings.push(
68            Diagnostic::new(Code::E3011)
69                .with_context("annotation", name)
70                .with_context("column rows", column.len().to_string())
71                .with_context("atoms", atoms.to_string()),
72        );
73    }
74}
75
76/// Verifies that every bond has two distinct, valid atom endpoints.
77///
78/// Self-bonds and endpoints beyond the atom extent are reported independently
79/// so malformed bonds do not conceal additional structural violations.
80fn check_bonds(data: &StructureData, atom_count: u32, findings: &mut Diagnostics) {
81    for (position, bond) in data.bonds.iter().enumerate() {
82        if bond.atom_a == bond.atom_b {
83            findings.push(Diagnostic::new(Code::E3007).with_context("bond", position.to_string()));
84        }
85
86        check_bond_endpoint(position, bond.atom_a.get(), atom_count, findings);
87        check_bond_endpoint(position, bond.atom_b.get(), atom_count, findings);
88    }
89}
90
91/// Checks one bond endpoint against the current atom extent.
92///
93/// Invalid endpoints are reported with both the bond position and atom index.
94fn check_bond_endpoint(bond: usize, atom: u32, atom_count: u32, findings: &mut Diagnostics) {
95    if atom < atom_count {
96        return;
97    }
98
99    findings.push(
100        Diagnostic::new(Code::E3006)
101            .with_context("bond", bond.to_string())
102            .with_context("atom", atom.to_string()),
103    );
104}
105
106/// Verifies coordinate values for every position declared present.
107///
108/// Ragged ensembles validate each independent model recursively. Single and
109/// dense stores are traversed directly without repeated model lookups.
110fn check_coordinates(data: &StructureData, findings: &mut Diagnostics) {
111    match &data.coords {
112        CoordinateStore::Single(block) => {
113            check_coordinate_block(data, block, 0, findings);
114        }
115        CoordinateStore::Dense { frames } => {
116            for (model, block) in frames.iter().enumerate() {
117                let Ok(model) = u32::try_from(model) else {
118                    findings
119                        .push(Diagnostic::new(Code::E3001).with_context("model", "exceeds u32"));
120                    return;
121                };
122
123                check_coordinate_block(data, block, model, findings);
124            }
125        }
126        CoordinateStore::Ragged { models } => {
127            check_ragged_models(models, findings);
128        }
129    }
130}
131
132/// Verifies finite coordinates for all atoms marked as positioned in one model.
133///
134/// Missing coordinates remain valid. Present coordinates must contain only
135/// finite floating-point components.
136fn check_coordinate_block(
137    data: &StructureData,
138    block: &CoordinateBlock,
139    model: u32,
140    findings: &mut Diagnostics,
141) {
142    let positions = block.as_slice();
143
144    for chunk in data.chunks.iter() {
145        let start = chunk.atoms().start;
146
147        for local in 0..chunk.len() {
148            if !chunk.has_position(local) {
149                continue;
150            }
151
152            let Some(atom) = start.checked_add(local) else {
153                findings.push(
154                    Diagnostic::new(Code::E3001)
155                        .with_context("model", model.to_string())
156                        .with_context("atom", "index overflow"),
157                );
158                continue;
159            };
160
161            let Some(position) = positions.get(atom as usize) else {
162                // The coordinate-count invariant reports truncated blocks.
163                continue;
164            };
165
166            if position.iter().all(|value| value.is_finite()) {
167                continue;
168            }
169
170            findings.push(
171                Diagnostic::new(Code::E3008)
172                    .with_context("model", model.to_string())
173                    .with_context("atom", atom.to_string()),
174            );
175        }
176    }
177}
178
179/// Recursively validates every independent structure in a ragged ensemble.
180///
181/// Diagnostics from each child carry the ragged-model index that produced
182/// them, while nested ragged structures naturally accumulate model context.
183fn check_ragged_models(models: &[Structure], findings: &mut Diagnostics) {
184    for (model, structure) in models.iter().enumerate() {
185        for finding in validate(structure.data()) {
186            findings.push(finding.with_context("ragged model", model.to_string()));
187        }
188    }
189}
190
191/// Every parent's children must lie inside the child table, and siblings must
192/// not overlap or run backwards.
193fn check_child_ranges(data: &StructureData, findings: &mut Diagnostics) {
194    let topology = &data.topology;
195    let shared_topology = matches!(&data.coords, CoordinateStore::Dense { .. });
196
197    let mut expected_chain = 0u32;
198    let mut shared_range: Option<(u32, u32)> = None;
199
200    for model in topology.models.iter() {
201        let Some(range) = topology.models.chains(model) else {
202            findings.push(
203                Diagnostic::new(Code::E3002)
204                    .with_message("a model has no chain range")
205                    .with_context("model", model.to_string()),
206            );
207            continue;
208        };
209
210        if range.start > range.end {
211            findings.push(
212                Diagnostic::new(Code::E3003)
213                    .with_message("a model's chain range runs backwards")
214                    .with_context("model", model.to_string()),
215            );
216        }
217
218        if range_exceeds_table(range.start, range.end, topology.chains.len()) {
219            findings.push(
220                Diagnostic::new(Code::E3002)
221                    .with_message("a model's chains run past the chain table")
222                    .with_context("model", model.to_string()),
223            );
224        }
225
226        if shared_topology {
227            let current = (range.start, range.end);
228
229            match shared_range {
230                Some(expected) if expected != current => {
231                    findings.push(
232                        Diagnostic::new(Code::E3003)
233                            .with_message("dense models do not share one chain range")
234                            .with_context("model", model.to_string()),
235                    );
236                }
237                None => shared_range = Some(current),
238                Some(_) => {}
239            }
240        } else {
241            if range.start < expected_chain {
242                findings.push(
243                    Diagnostic::new(Code::E3003)
244                        .with_message("models share chains")
245                        .with_context("model", model.to_string()),
246                );
247            }
248
249            expected_chain = expected_chain.max(range.end);
250        }
251    }
252
253    let mut expected_residue = 0u32;
254
255    for chain in topology.chains.iter() {
256        let Some(range) = topology.chains.residues(chain) else {
257            findings.push(
258                Diagnostic::new(Code::E3002)
259                    .with_message("a chain has no residue range")
260                    .with_context("chain", chain.to_string()),
261            );
262            continue;
263        };
264
265        if range.start > range.end {
266            findings.push(
267                Diagnostic::new(Code::E3003)
268                    .with_message("a chain's residue range runs backwards")
269                    .with_context("chain", chain.to_string()),
270            );
271        }
272
273        if range_exceeds_table(range.start, range.end, topology.residues.len()) {
274            findings.push(
275                Diagnostic::new(Code::E3002)
276                    .with_message("a chain's residues run past the residue table")
277                    .with_context("chain", chain.to_string()),
278            );
279        }
280
281        if range.start < expected_residue {
282            findings.push(
283                Diagnostic::new(Code::E3003)
284                    .with_message("chains share residues")
285                    .with_context("chain", chain.to_string()),
286            );
287        }
288
289        expected_residue = expected_residue.max(range.end);
290    }
291}
292
293/// Determines whether either endpoint of a child range exceeds its table.
294///
295/// Both endpoints are checked so an inverted range whose `start` lies beyond
296/// the table cannot evade validation merely because its `end` is smaller.
297fn range_exceeds_table(start: u32, end: u32, table_len: usize) -> bool {
298    let start = usize::try_from(start);
299    let end = usize::try_from(end);
300
301    match (start, end) {
302        (Ok(start), Ok(end)) => start > table_len || end > table_len,
303        _ => true,
304    }
305}
306
307/// Every atom must belong to exactly one residue, which means the residues'
308/// atom ranges must tile the atom order without gaps or overlaps.
309fn check_atom_coverage(data: &StructureData, atoms_in_chunks: u32, findings: &mut Diagnostics) {
310    let residues = &data.topology.residues;
311
312    let Ok(residue_count) = u32::try_from(residues.len()) else {
313        findings.push(Diagnostic::new(Code::E3001).with_context("residues", "exceeds u32"));
314        return;
315    };
316
317    let mut expected = 0u32;
318
319    for position in 0..residue_count {
320        let residue = ResidueIndex::new(position);
321
322        let Some(range) = residues.atoms(residue) else {
323            findings.push(
324                Diagnostic::new(Code::E3004)
325                    .with_message("a residue has no atom range")
326                    .with_context("residue", position.to_string()),
327            );
328            continue;
329        };
330
331        if range.start > range.end {
332            findings.push(
333                Diagnostic::new(Code::E3004)
334                    .with_message("a residue's atom range runs backwards")
335                    .with_context("residue", position.to_string()),
336            );
337        }
338
339        if range.start != expected {
340            findings.push(
341                Diagnostic::new(Code::E3004)
342                    .with_message("residues do not cover the atoms contiguously")
343                    .with_context("residue", position.to_string())
344                    .with_context("expected first atom", expected.to_string()),
345            );
346        }
347
348        expected = expected.max(range.end);
349    }
350
351    if expected != atoms_in_chunks {
352        findings.push(
353            Diagnostic::new(Code::E3401)
354                .with_message("the residue table and the atom chunks disagree on the atom count")
355                .with_context("residues account for", expected.to_string())
356                .with_context("chunks hold", atoms_in_chunks.to_string()),
357        );
358    }
359}
360
361/// Every chain must reference an entity that exists.
362///
363/// Missing entity references and references beyond the entity table are both
364/// structural violations.
365fn check_entity_references(data: &StructureData, findings: &mut Diagnostics) {
366    let topology = &data.topology;
367
368    for chain in topology.chains.iter() {
369        let Some(entity) = topology.chains.entity(chain) else {
370            findings.push(
371                Diagnostic::new(Code::E3005)
372                    .with_context("chain", chain.to_string())
373                    .with_context("entity", "missing"),
374            );
375            continue;
376        };
377
378        if entity.as_usize() < topology.entities.len() {
379            continue;
380        }
381
382        findings.push(
383            Diagnostic::new(Code::E3005)
384                .with_context("chain", chain.to_string())
385                .with_context("entity", entity.to_string()),
386        );
387    }
388}
389
390/// Models that share a topology must share an atom count.
391fn check_coordinate_counts(data: &StructureData, atoms: u32, findings: &mut Diagnostics) {
392    match &data.coords {
393        CoordinateStore::Single(block) => {
394            if block.len() != atoms {
395                findings.push(
396                    Diagnostic::new(Code::E3401)
397                        .with_context("positions", block.len().to_string())
398                        .with_context("atoms", atoms.to_string()),
399                );
400            }
401        }
402        CoordinateStore::Dense { frames } => {
403            for (index, frame) in frames.iter().enumerate() {
404                if frame.len() == atoms {
405                    continue;
406                }
407
408                findings.push(
409                    Diagnostic::new(Code::E3010)
410                        .with_context("model", index.to_string())
411                        .with_context("positions", frame.len().to_string())
412                        .with_context("atoms", atoms.to_string()),
413                );
414            }
415        }
416        CoordinateStore::Ragged { .. } => {}
417    }
418}
419
420/// Occupancy, where it was recorded, must lie between zero and one.
421fn check_occupancies(data: &StructureData, findings: &mut Diagnostics) {
422    for chunk in data.chunks.iter() {
423        let start = chunk.atoms().start;
424
425        for local in 0..chunk.len() {
426            let Some((occupancy, presence)) = chunk.occupancy(local) else {
427                continue;
428            };
429
430            if !presence.is_present() || (0.0..=1.0).contains(&occupancy) {
431                continue;
432            }
433
434            let Some(atom) = start.checked_add(local) else {
435                findings.push(Diagnostic::new(Code::E3001).with_context("atom", "index overflow"));
436                continue;
437            };
438
439            findings.push(
440                Diagnostic::new(Code::E3009)
441                    .with_context("atom", atom.to_string())
442                    .with_context("occupancy", occupancy.to_string()),
443            );
444        }
445    }
446}
447
448#[cfg(test)]
449#[path = "validate_tests.rs"]
450mod tests;