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