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