molframe-core 0.6.1

Core types, storage and analysis contracts for molframe
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
//! The invariants a structure must satisfy.
//!
//! These are checked when a structure is built and whenever an edit is
//! committed, so a violation is caught at the one place that could have caused
//! it rather than surfacing later as an implausible number. Every violation is
//! reported rather than only the first, because a structure with a broken
//! hierarchy usually has more than one thing wrong with it.
//!
//! A violation here is a defect in whatever built the structure. No input file,
//! however malformed, should be able to produce one — a reader that cannot make
//! sense of a file emits a diagnostic and declines, rather than building
//! something inconsistent.

use super::data::{CoordinateStore, StructureData};
use crate::Structure;
use crate::coords::CoordinateBlock;
use crate::diagnostic::{Code, Diagnostic, Diagnostics};
use crate::index::ResidueIndex;

/// Checks every invariant, returning what was violated.
///
/// An empty result means the structure is sound.
#[must_use]
pub fn validate(data: &StructureData) -> Vec<Diagnostic> {
    let mut findings = Diagnostics::new();
    let atoms = atoms_in_chunks(data);

    check_child_ranges(data, &mut findings);
    check_atom_coverage(data, atoms, &mut findings);
    check_entity_references(data, &mut findings);
    check_bonds(data, atoms, &mut findings);
    check_anisotropy(data, atoms, &mut findings);
    check_annotations(data, &mut findings);
    check_secondary_sources(data, &mut findings);
    check_coordinate_counts(data, atoms, &mut findings);
    check_coordinates(data, &mut findings);

    findings.finish()
}

/// Checks the invariants that a coordinate-only edit can change.
pub(crate) fn validate_coordinate_edit(data: &StructureData) -> Vec<Diagnostic> {
    let mut findings = Diagnostics::new();
    let atoms = atoms_in_chunks(data);
    check_coordinate_counts(data, atoms, &mut findings);
    check_coordinates(data, &mut findings);
    findings.finish()
}

/// Returns the atom extent declared by the structure's chunk sequence.
///
/// An empty chunk collection represents an empty atom extent.
fn atoms_in_chunks(data: &StructureData) -> u32 {
    data.chunks.last().map_or(0, |chunk| chunk.atoms().end)
}

/// Verifies that all annotation columns remain aligned with the atom table.
///
/// Every annotation must contain exactly one row for each atom.
fn check_annotations(data: &StructureData, findings: &mut Diagnostics) {
    let atoms = data.atom_count();

    for (name, column) in data.annotations.iter() {
        if column.len() == atoms {
            continue;
        }

        findings.push(
            Diagnostic::new(Code::E3011)
                .with_context("annotation", name)
                .with_context("column rows", column.len().to_string())
                .with_context("atoms", atoms.to_string()),
        );
    }
}

/// Verifies that every secondary-structure state carries its source.
fn check_secondary_sources(data: &StructureData, findings: &mut Diagnostics) {
    let states = data.secondary_structure.len();
    let sources = data.secondary_source.len();
    if states != sources {
        findings.push(
            Diagnostic::new(Code::E3011)
                .with_context("annotation", "secondary structure source")
                .with_context("column rows", sources.to_string())
                .with_context("states", states.to_string()),
        );
    }
}

/// Verifies that every bond has two distinct, valid atom endpoints.
///
/// Self-bonds and endpoints beyond the atom extent are reported independently
/// so malformed bonds do not conceal additional structural violations.
fn check_bonds(data: &StructureData, atom_count: u32, findings: &mut Diagnostics) {
    for (position, bond) in data.bonds.iter().enumerate() {
        if bond.atom_a == bond.atom_b {
            findings.push(Diagnostic::new(Code::E3007).with_context("bond", position.to_string()));
        }

        check_bond_endpoint(position, bond.atom_a.get(), atom_count, findings);
        check_bond_endpoint(position, bond.atom_b.get(), atom_count, findings);
    }
}

/// Checks one bond endpoint against the current atom extent.
///
/// Invalid endpoints are reported with both the bond position and atom index.
fn check_bond_endpoint(bond: usize, atom: u32, atom_count: u32, findings: &mut Diagnostics) {
    if atom < atom_count {
        return;
    }

    findings.push(
        Diagnostic::new(Code::E3006)
            .with_context("bond", bond.to_string())
            .with_context("atom", atom.to_string()),
    );
}

/// Verifies that every ellipsoid names a valid atom row.
///
/// An endpoint beyond the atom extent is reported with both the row position
/// and the atom index so a malformed row does not conceal what it pointed at.
fn check_anisotropy(data: &StructureData, atom_count: u32, findings: &mut Diagnostics) {
    for (position, record) in data.anisotropy.iter().enumerate() {
        if record.atom.get() < atom_count {
            continue;
        }
        findings.push(
            Diagnostic::new(Code::E3006)
                .with_context("anisotropy", position.to_string())
                .with_context("atom", record.atom.get().to_string()),
        );
    }
}

/// Verifies coordinate values for every position declared present.
///
/// Ragged ensembles validate each independent model recursively. Single and
/// dense stores are traversed directly without repeated model lookups.
fn check_coordinates(data: &StructureData, findings: &mut Diagnostics) {
    match &data.coords {
        CoordinateStore::Single(block) => {
            check_coordinate_block(data, block, 0, findings);
        }
        CoordinateStore::Dense { frames } => {
            for (model, block) in frames.iter().enumerate() {
                let Ok(model) = u32::try_from(model) else {
                    findings
                        .push(Diagnostic::new(Code::E3001).with_context("model", "exceeds u32"));
                    return;
                };

                check_coordinate_block(data, block, model, findings);
            }
        }
        CoordinateStore::Ragged { models } => {
            check_ragged_models(models, findings);
        }
    }
}

/// Verifies finite coordinates for all atoms marked as positioned in one model.
///
/// Missing coordinates remain valid. Present coordinates must contain only
/// finite floating-point components.
fn check_coordinate_block(
    data: &StructureData,
    block: &CoordinateBlock,
    model: u32,
    findings: &mut Diagnostics,
) {
    let positions = block.as_slice();

    for chunk in data.chunks.iter() {
        let start = chunk.atoms().start;

        for local in 0..chunk.len() {
            if !chunk.has_position(local) {
                continue;
            }

            let Some(atom) = start.checked_add(local) else {
                findings.push(
                    Diagnostic::new(Code::E3001)
                        .with_context("model", model.to_string())
                        .with_context("atom", "index overflow"),
                );
                continue;
            };

            let Some(position) = positions.get(atom as usize) else {
                // The coordinate-count invariant reports truncated blocks.
                continue;
            };

            if position.iter().all(|value| value.is_finite()) {
                continue;
            }

            findings.push(
                Diagnostic::new(Code::E3008)
                    .with_context("model", model.to_string())
                    .with_context("atom", atom.to_string()),
            );
        }
    }
}

/// Recursively validates every independent structure in a ragged ensemble.
///
/// Diagnostics from each child carry the ragged-model index that produced
/// them, while nested ragged structures naturally accumulate model context.
fn check_ragged_models(models: &[Structure], findings: &mut Diagnostics) {
    for (model, structure) in models.iter().enumerate() {
        for finding in validate(structure.data()) {
            findings.push(finding.with_context("ragged model", model.to_string()));
        }
    }
}

/// Every parent's children must lie inside the child table, and siblings must
/// not overlap or run backwards.
fn check_child_ranges(data: &StructureData, findings: &mut Diagnostics) {
    let topology = &data.topology;
    let shared_topology = matches!(&data.coords, CoordinateStore::Dense { .. });

    let mut expected_chain = 0u32;
    let mut shared_range: Option<(u32, u32)> = None;

    for model in topology.models.iter() {
        let Some(range) = topology.models.chains(model) else {
            findings.push(
                Diagnostic::new(Code::E3002)
                    .with_message("a model has no chain range")
                    .with_context("model", model.to_string()),
            );
            continue;
        };

        if range.start > range.end {
            findings.push(
                Diagnostic::new(Code::E3003)
                    .with_message("a model's chain range runs backwards")
                    .with_context("model", model.to_string()),
            );
        }

        if range_exceeds_table(range.start, range.end, topology.chains.len()) {
            findings.push(
                Diagnostic::new(Code::E3002)
                    .with_message("a model's chains run past the chain table")
                    .with_context("model", model.to_string()),
            );
        }

        if shared_topology {
            let current = (range.start, range.end);

            match shared_range {
                Some(expected) if expected != current => {
                    findings.push(
                        Diagnostic::new(Code::E3003)
                            .with_message("dense models do not share one chain range")
                            .with_context("model", model.to_string()),
                    );
                }
                None => shared_range = Some(current),
                Some(_) => {}
            }
        } else {
            if range.start < expected_chain {
                findings.push(
                    Diagnostic::new(Code::E3003)
                        .with_message("models share chains")
                        .with_context("model", model.to_string()),
                );
            }

            expected_chain = expected_chain.max(range.end);
        }
    }

    let mut expected_residue = 0u32;

    for chain in topology.chains.iter() {
        let Some(range) = topology.chains.residues(chain) else {
            findings.push(
                Diagnostic::new(Code::E3002)
                    .with_message("a chain has no residue range")
                    .with_context("chain", chain.to_string()),
            );
            continue;
        };

        if range.start > range.end {
            findings.push(
                Diagnostic::new(Code::E3003)
                    .with_message("a chain's residue range runs backwards")
                    .with_context("chain", chain.to_string()),
            );
        }

        if range_exceeds_table(range.start, range.end, topology.residues.len()) {
            findings.push(
                Diagnostic::new(Code::E3002)
                    .with_message("a chain's residues run past the residue table")
                    .with_context("chain", chain.to_string()),
            );
        }

        if range.start < expected_residue {
            findings.push(
                Diagnostic::new(Code::E3003)
                    .with_message("chains share residues")
                    .with_context("chain", chain.to_string()),
            );
        }

        expected_residue = expected_residue.max(range.end);
    }
}

/// Determines whether either endpoint of a child range exceeds its table.
///
/// Both endpoints are checked so an inverted range whose `start` lies beyond
/// the table cannot evade validation merely because its `end` is smaller.
fn range_exceeds_table(start: u32, end: u32, table_len: usize) -> bool {
    let start = usize::try_from(start);
    let end = usize::try_from(end);

    match (start, end) {
        (Ok(start), Ok(end)) => start > table_len || end > table_len,
        _ => true,
    }
}

/// Every atom must belong to exactly one residue, which means the residues'
/// atom ranges must tile the atom order without gaps or overlaps.
fn check_atom_coverage(data: &StructureData, atoms_in_chunks: u32, findings: &mut Diagnostics) {
    let residues = &data.topology.residues;

    let Ok(residue_count) = u32::try_from(residues.len()) else {
        findings.push(Diagnostic::new(Code::E3001).with_context("residues", "exceeds u32"));
        return;
    };

    let mut expected = 0u32;

    for position in 0..residue_count {
        let residue = ResidueIndex::new(position);

        let Some(range) = residues.atoms(residue) else {
            findings.push(
                Diagnostic::new(Code::E3004)
                    .with_message("a residue has no atom range")
                    .with_context("residue", position.to_string()),
            );
            continue;
        };

        if range.start > range.end {
            findings.push(
                Diagnostic::new(Code::E3004)
                    .with_message("a residue's atom range runs backwards")
                    .with_context("residue", position.to_string()),
            );
        }

        if range.start != expected {
            findings.push(
                Diagnostic::new(Code::E3004)
                    .with_message("residues do not cover the atoms contiguously")
                    .with_context("residue", position.to_string())
                    .with_context("expected first atom", expected.to_string()),
            );
        }

        expected = expected.max(range.end);
    }

    if expected != atoms_in_chunks {
        findings.push(
            Diagnostic::new(Code::E3401)
                .with_message("the residue table and the atom chunks disagree on the atom count")
                .with_context("residues account for", expected.to_string())
                .with_context("chunks hold", atoms_in_chunks.to_string()),
        );
    }
}

/// Every chain must reference an entity that exists.
///
/// Missing entity references and references beyond the entity table are both
/// structural violations.
fn check_entity_references(data: &StructureData, findings: &mut Diagnostics) {
    let topology = &data.topology;

    for chain in topology.chains.iter() {
        let Some(entity) = topology.chains.entity(chain) else {
            findings.push(
                Diagnostic::new(Code::E3005)
                    .with_context("chain", chain.to_string())
                    .with_context("entity", "missing"),
            );
            continue;
        };

        if entity.as_usize() < topology.entities.len() {
            continue;
        }

        findings.push(
            Diagnostic::new(Code::E3005)
                .with_context("chain", chain.to_string())
                .with_context("entity", entity.to_string()),
        );
    }
}

/// Models that share a topology must share an atom count.
fn check_coordinate_counts(data: &StructureData, atoms: u32, findings: &mut Diagnostics) {
    match &data.coords {
        CoordinateStore::Single(block) => {
            if block.len() != atoms {
                findings.push(
                    Diagnostic::new(Code::E3401)
                        .with_context("positions", block.len().to_string())
                        .with_context("atoms", atoms.to_string()),
                );
            }
        }
        CoordinateStore::Dense { frames } => {
            for (index, frame) in frames.iter().enumerate() {
                if frame.len() == atoms {
                    continue;
                }

                findings.push(
                    Diagnostic::new(Code::E3010)
                        .with_context("model", index.to_string())
                        .with_context("positions", frame.len().to_string())
                        .with_context("atoms", atoms.to_string()),
                );
            }
        }
        CoordinateStore::Ragged { .. } => {}
    }
}

#[cfg(test)]
#[path = "validate_tests.rs"]
mod tests;