Skip to main content

molframe_core/structure/
disorder.rs

1//! Explicit selection of alternate conformations.
2//!
3//! Reading never collapses disorder. This module turns a declared analysis
4//! policy into an atom selection while leaving the stored rows untouched.
5
6mod compat;
7
8use super::{AtomRef, ResidueRef, Structure};
9use crate::contract::{AltlocPolicy, Analysis, AnalysisPolicy, Coverage, Indeterminacy, Quality};
10use crate::diagnostic::{Code, Diagnostic};
11use crate::hashing::IdentityBuildHasher;
12use crate::selection::AtomSelection;
13use crate::symbol::{AltId, SymbolId};
14pub use compat::{altloc_compatible, compatible_pairs};
15use hashbrown::HashMap;
16
17/// Occupancy scratch keyed by an identifier the engine assigned itself.
18///
19/// The keys are altloc and symbol identifiers, never attacker-chosen, so the
20/// keyed pseudo-random default hasher buys nothing and costs a lookup each.
21type ScoreTable<K, V> = HashMap<K, V, IdentityBuildHasher>;
22
23/// Accumulated occupancy for one alternate-conformation label.
24///
25/// `order` records when the label was first encountered so occupancy ties
26/// preserve the original deterministic first-label behavior.
27#[derive(Clone, Copy)]
28struct LabelScore {
29    occupancy: f32,
30    order: usize,
31}
32
33/// The label selected for one residue or chain and whether disorder was seen.
34#[derive(Clone, Copy)]
35struct LabelChoice {
36    selected: Option<AltId>,
37    has_altlocs: bool,
38}
39
40/// Internal result of resolving one alternate-conformation policy.
41///
42/// Besides the selected atoms, resolution carries metadata already discovered
43/// while traversing atoms so callers do not need additional full-structure
44/// scans.
45struct AltlocResolution {
46    selection: AtomSelection,
47    missing_label: bool,
48    has_altlocs: bool,
49}
50
51/// Best retained row for one atom name under per-atom occupancy resolution.
52#[derive(Clone, Copy)]
53struct AtomChoice {
54    atom: u32,
55    occupancy: f32,
56}
57
58impl Structure {
59    /// Selects atom rows under the policy's alternate-conformation rule.
60    ///
61    /// Blank-labelled atoms are retained under every rule because they belong
62    /// to every conformation. The stored structure is never changed.
63    ///
64    /// # Panics
65    ///
66    /// This function does not intentionally panic when an internal selector
67    /// reports an impossible cardinality; such a result is returned as an
68    /// indeterminate analysis instead.
69    #[must_use]
70    pub fn resolve_altlocs(&self, policy: &AnalysisPolicy) -> Analysis<AtomSelection> {
71        if let Err(finding) = self.require_resolved_model() {
72            return Analysis::indeterminate(
73                Indeterminacy::UnresolvedConformations,
74                Coverage {
75                    intended: 0,
76                    used: 0,
77                    missing: 0,
78                    ambiguous: 0,
79                },
80                policy,
81            )
82            .with_warning(finding);
83        }
84
85        let total = self.atom_count();
86
87        let AltlocResolution {
88            selection,
89            missing_label,
90            has_altlocs,
91        } = match &policy.altloc {
92            AltlocPolicy::KeepAll => AltlocResolution {
93                selection: AtomSelection::All(total),
94                missing_label: false,
95                has_altlocs: self.has_altlocs(),
96            },
97            AltlocPolicy::ConformerConsistent => self.conformer_consistent(),
98            AltlocPolicy::Label(label) => self.named_conformer(label),
99            AltlocPolicy::First => self.per_residue(&FirstLabel),
100            AltlocPolicy::HighestOccupancyPerResidue => self.per_residue(&HighestOccupancy),
101            AltlocPolicy::HighestOccupancyPerAtom => self.per_atom_occupancy(),
102        };
103
104        let selected_rows = selection.len();
105        let used = match u32::try_from(selected_rows) {
106            Ok(used) if used <= total => used,
107            _ => {
108                return Analysis::indeterminate(
109                    Indeterminacy::UnresolvedConformations,
110                    Coverage {
111                        intended: total,
112                        used: 0,
113                        missing: total,
114                        ambiguous: 0,
115                    },
116                    policy,
117                )
118                .with_warning(
119                    Diagnostic::new(Code::E3001)
120                        .with_context("selection rows", selected_rows.to_string())
121                        .with_context("atoms", total.to_string()),
122                );
123            }
124        };
125
126        let mut result = Analysis::complete(
127            selection,
128            Coverage {
129                intended: total,
130                used,
131                missing: 0,
132                ambiguous: total - used,
133            },
134            policy,
135        );
136
137        if matches!(&policy.altloc, AltlocPolicy::KeepAll) && has_altlocs {
138            result.degrade(Quality::Ambiguous);
139        }
140
141        if policy.altloc.is_hazardous() && has_altlocs {
142            result.warnings.push(Diagnostic::new(Code::W6001));
143        }
144
145        if missing_label {
146            result.warnings.push(
147                Diagnostic::new(Code::W4003)
148                    .with_context("altloc", named_policy_label(&policy.altloc)),
149            );
150        }
151
152        result
153    }
154
155    /// Returns whether the structure contains any non-blank alternate label.
156    ///
157    /// This scan is needed only for policies such as `KeepAll` that otherwise
158    /// do not inspect individual atom rows.
159    fn has_altlocs(&self) -> bool {
160        self.data()
161            .atoms()
162            .any(|atom| atom.alt_id().is_some_and(|label| !label.is_blank()))
163    }
164
165    /// Resolves a specifically named alternate conformation.
166    ///
167    /// Blank atoms are always retained. A label absent from either the symbol
168    /// dictionary or the atom rows yields the blank-only selection and marks
169    /// the requested label as missing.
170    fn named_conformer(&self, label: &str) -> AltlocResolution {
171        let wanted = self.data().dictionary.get(label).and_then(AltId::labelled);
172
173        let requested_valid = wanted.is_some();
174        let (selection, present, has_altlocs) = self.select_label(wanted);
175
176        AltlocResolution {
177            selection,
178            missing_label: !requested_valid || !present,
179            has_altlocs,
180        }
181    }
182
183    /// Selects blank rows together with one optional alternate label.
184    ///
185    /// Returns the selection, whether the requested non-blank label was
186    /// encountered, and whether any alternate conformations exist.
187    fn select_label(&self, selected: Option<AltId>) -> (AtomSelection, bool, bool) {
188        let mut positions = Vec::new();
189        let mut selected_present = false;
190        let mut has_altlocs = false;
191
192        for atom in self.data().atoms() {
193            let Some(label) = atom.alt_id() else {
194                continue;
195            };
196
197            if !label.is_blank() {
198                has_altlocs = true;
199            }
200
201            if Some(label) == selected {
202                selected_present = true;
203            }
204
205            if label.is_blank() || Some(label) == selected {
206                positions.push(atom.index().get());
207            }
208        }
209
210        (
211            AtomSelection::from_sorted(positions),
212            selected_present,
213            has_altlocs,
214        )
215    }
216
217    /// Chooses one occupancy-maximizing label consistently for each chain.
218    ///
219    /// A reusable score table avoids allocating fresh aggregation storage for
220    /// every chain while preserving deterministic first-seen tie breaking.
221    fn conformer_consistent(&self) -> AltlocResolution {
222        let mut positions = Vec::new();
223        let mut scores = ScoreTable::default();
224        let mut has_altlocs = false;
225
226        for chain in self.data().chains() {
227            let choice = best_label(chain.residues().flat_map(ResidueRef::atoms), &mut scores);
228
229            has_altlocs |= choice.has_altlocs;
230
231            for atom in chain.residues().flat_map(ResidueRef::atoms) {
232                if keeps_label(atom, choice.selected) {
233                    positions.push(atom.index().get());
234                }
235            }
236        }
237
238        AltlocResolution {
239            selection: AtomSelection::from_sorted(positions),
240            missing_label: false,
241            has_altlocs,
242        }
243    }
244
245    /// Resolves alternate conformations independently for every residue.
246    ///
247    /// The chooser receives reusable occupancy scratch storage so repeated
248    /// residue resolution retains hash-table capacity instead of reallocating.
249    fn per_residue(&self, chooser: &impl LabelChooser) -> AltlocResolution {
250        let mut positions = Vec::new();
251        let mut scores = ScoreTable::default();
252        let mut has_altlocs = false;
253
254        for residue in self.data().residues() {
255            let choice = chooser.choose(residue, &mut scores);
256            has_altlocs |= choice.has_altlocs;
257
258            positions.extend(
259                residue
260                    .atoms()
261                    .filter(|atom| keeps_label(*atom, choice.selected))
262                    .map(|atom| atom.index().get()),
263            );
264        }
265
266        AltlocResolution {
267            selection: AtomSelection::from_sorted(positions),
268            missing_label: false,
269            has_altlocs,
270        }
271    }
272
273    /// Selects the highest-occupancy alternate row independently per atom name.
274    ///
275    /// Choices are reused across residues and selected atoms are emitted during
276    /// a second ordered residue traversal, avoiding a global sort of results.
277    fn per_atom_occupancy(&self) -> AltlocResolution {
278        let mut positions = Vec::new();
279        let mut choices: ScoreTable<SymbolId, AtomChoice> = ScoreTable::default();
280        let mut has_altlocs = false;
281
282        for residue in self.data().residues() {
283            choices.clear();
284
285            for atom in residue.atoms() {
286                let Some(label) = atom.alt_id() else {
287                    continue;
288                };
289
290                if label.is_blank() {
291                    continue;
292                }
293
294                has_altlocs = true;
295
296                let Some(name) = atom.name_symbol() else {
297                    continue;
298                };
299
300                record_atom_choice(&mut choices, name, atom);
301            }
302
303            for atom in residue.atoms() {
304                let Some(label) = atom.alt_id() else {
305                    continue;
306                };
307
308                if label.is_blank() {
309                    positions.push(atom.index().get());
310                    continue;
311                }
312
313                let Some(name) = atom.name_symbol() else {
314                    continue;
315                };
316
317                let Some(choice) = choices.get(&name) else {
318                    continue;
319                };
320
321                if choice.atom == atom.index().get() {
322                    positions.push(choice.atom);
323                }
324            }
325        }
326
327        AltlocResolution {
328            selection: AtomSelection::from_sorted(positions),
329            missing_label: false,
330            has_altlocs,
331        }
332    }
333}
334
335/// Chooses one alternate label for a residue.
336///
337/// Implementations may use the supplied score map as reusable scratch storage;
338/// callers clear or overwrite it through the chooser implementation.
339trait LabelChooser {
340    /// Returns the selected label together with whether the residue is disordered.
341    fn choose(
342        &self,
343        residue: ResidueRef<'_>,
344        scores: &mut ScoreTable<AltId, LabelScore>,
345    ) -> LabelChoice;
346}
347
348/// Chooses the first non-blank alternate label in atom order.
349struct FirstLabel;
350
351impl LabelChooser for FirstLabel {
352    /// Returns the first non-blank alternate label present in the residue.
353    fn choose(
354        &self,
355        residue: ResidueRef<'_>,
356        _scores: &mut ScoreTable<AltId, LabelScore>,
357    ) -> LabelChoice {
358        let selected = residue
359            .atoms()
360            .filter_map(AtomRef::alt_id)
361            .find(|label| !label.is_blank());
362
363        LabelChoice {
364            selected,
365            has_altlocs: selected.is_some(),
366        }
367    }
368}
369
370/// Chooses the alternate label with the greatest accumulated occupancy.
371struct HighestOccupancy;
372
373impl LabelChooser for HighestOccupancy {
374    /// Aggregates occupancy by label and preserves first-seen ordering on ties.
375    fn choose(
376        &self,
377        residue: ResidueRef<'_>,
378        scores: &mut ScoreTable<AltId, LabelScore>,
379    ) -> LabelChoice {
380        best_label(residue.atoms(), scores)
381    }
382}
383
384/// Finds the occupancy-maximizing non-blank alternate label.
385///
386/// The reusable hash table gives expected constant-time label aggregation.
387/// First-seen order is stored explicitly so hash iteration order cannot affect
388/// ties or the original deterministic behavior.
389fn best_label<'a>(
390    atoms: impl Iterator<Item = AtomRef<'a>>,
391    scores: &mut ScoreTable<AltId, LabelScore>,
392) -> LabelChoice {
393    scores.clear();
394
395    for atom in atoms {
396        let Some(label) = atom.alt_id() else {
397            continue;
398        };
399
400        if label.is_blank() {
401            continue;
402        }
403
404        let occupancy = occupancy(atom);
405        let order = scores.len();
406
407        scores
408            .entry(label)
409            .and_modify(|score| score.occupancy += occupancy)
410            .or_insert(LabelScore { occupancy, order });
411    }
412
413    LabelChoice {
414        selected: best_scored_label(scores),
415        has_altlocs: !scores.is_empty(),
416    }
417}
418
419/// Selects the winning label from accumulated occupancy scores.
420///
421/// Selection reproduces ordered `>` semantics exactly: ties retain the first
422/// label encountered, and a NaN accumulated by the first label continues to
423/// dominate exactly as it did under the original sequential comparison.
424fn best_scored_label(scores: &ScoreTable<AltId, LabelScore>) -> Option<AltId> {
425    let mut earliest: Option<(AltId, LabelScore)> = None;
426    let mut best_finite: Option<(AltId, LabelScore)> = None;
427
428    for (&label, &score) in scores {
429        if earliest.is_none_or(|(_, current)| score.order < current.order) {
430            earliest = Some((label, score));
431        }
432
433        if score.occupancy.is_nan() {
434            continue;
435        }
436
437        if best_finite.is_none_or(|(_, current)| {
438            score.occupancy > current.occupancy
439                || (score.occupancy.to_bits() == current.occupancy.to_bits()
440                    && score.order < current.order)
441        }) {
442            best_finite = Some((label, score));
443        }
444    }
445
446    match earliest {
447        Some((label, score)) if score.occupancy.is_nan() => Some(label),
448        Some(_) => best_finite.map(|(label, _)| label),
449        None => None,
450    }
451}
452
453/// Retains the first atom of equal occupancy; only a strictly greater one replaces it.
454fn record_atom_choice(
455    choices: &mut ScoreTable<SymbolId, AtomChoice>,
456    name: SymbolId,
457    atom: AtomRef<'_>,
458) {
459    let candidate = AtomChoice {
460        atom: atom.index().get(),
461        occupancy: occupancy(atom),
462    };
463
464    choices
465        .entry(name)
466        .and_modify(|current| {
467            if candidate.occupancy > current.occupancy {
468                *current = candidate;
469            }
470        })
471        .or_insert(candidate);
472}
473
474/// Returns the recorded atom occupancy, defaulting absent values to one.
475fn occupancy(atom: AtomRef<'_>) -> f32 {
476    let Some(value) = atom.occupancy() else {
477        return 1.0;
478    };
479    value
480}
481
482/// Whether an atom belongs to the selected conformation (blank labels always do).
483fn keeps_label(atom: AtomRef<'_>, selected: Option<AltId>) -> bool {
484    atom.alt_id()
485        .is_some_and(|label| label.is_blank() || Some(label) == selected)
486}
487
488/// Owned diagnostic context for a named policy; allocates only on the missing-label warning.
489fn named_policy_label(policy: &AltlocPolicy) -> Box<str> {
490    match policy {
491        AltlocPolicy::Label(label) => label.clone(),
492        _ => "".into(),
493    }
494}
495
496#[cfg(test)]
497#[path = "disorder_tests.rs"]
498mod tests;