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