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