Skip to main content

molframe_core/contract/
analysis.rs

1//! A result, and everything needed to judge it.
2//!
3//! An analysis returns a value *and* what it had to assume to produce one: how
4//! much of the intended data was actually there, which decisions were made on
5//! the caller's behalf, and where the inputs came from. The value alone is the
6//! part every library gives you; the rest is what makes it citable.
7//!
8//! Declining to answer is a result in its own right. A library that cannot
9//! return "no defensible number exists here" will always return a
10//! defensible-looking wrong one instead. So an indeterminate analysis has no
11//! value to misread: its [`Outcome`] holds the reason in the value's place.
12
13use super::outcome::{Indeterminacy, Outcome, Quality};
14use super::policy::{AnalysisPolicy, Fingerprint, PolicyField};
15use super::provenance::Provenance;
16use crate::diagnostic::Diagnostic;
17
18/// How far an analysis got.
19#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
20#[non_exhaustive]
21pub enum Status {
22    /// Every intended atom was available and unambiguous.
23    #[default]
24    Complete,
25    /// Computed, but some intended atoms were not available.
26    Partial,
27    /// Several defensible answers exist; one was chosen deterministically.
28    Ambiguous,
29    /// No defensible single answer exists under this policy.
30    ///
31    /// An analysis in this state has no value: see [`Outcome::Indeterminate`].
32    Indeterminate,
33}
34
35impl Status {
36    /// Returns true when the value may be used as an answer.
37    #[must_use]
38    pub const fn is_usable(self) -> bool {
39        !matches!(self, Self::Indeterminate)
40    }
41}
42
43/// How much of what an analysis meant to use it actually used.
44///
45/// "Intended" comes from what a component should contain, not from what the
46/// file happened to have — so a residue whose side chain was never modelled
47/// reduces coverage rather than silently shrinking the calculation.
48#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
49pub struct Coverage {
50    /// Atoms the analysis meant to use.
51    pub intended: u32,
52    /// Atoms it did use.
53    pub used: u32,
54    /// Atoms absent from the model.
55    pub missing: u32,
56    /// Atoms present but not uniquely resolvable.
57    pub ambiguous: u32,
58}
59
60impl Coverage {
61    /// Coverage for an analysis that used everything it meant to.
62    #[must_use]
63    pub const fn complete(atoms: u32) -> Self {
64        Self {
65            intended: atoms,
66            used: atoms,
67            missing: 0,
68            ambiguous: 0,
69        }
70    }
71
72    /// The fraction of intended atoms that were used.
73    ///
74    /// An analysis over two fifths of the intended atoms is not the same
75    /// quantity as one over all of them, and this is what says so.
76    ///
77    /// # Panics
78    /// The checked conversion accepts every unsigned 32-bit count.
79    #[must_use]
80    pub fn fraction(self) -> f64 {
81        if self.intended == 0 {
82            1.0
83        } else {
84            f64::from(self.used) / f64::from(self.intended)
85        }
86    }
87
88    /// Returns true when nothing was missing or ambiguous.
89    #[must_use]
90    pub const fn is_complete(self) -> bool {
91        self.missing == 0 && self.ambiguous == 0 && self.used == self.intended
92    }
93}
94
95/// How much a decision changed the answer, where that is known.
96///
97/// An impact is a measurement or it is nothing. There is deliberately no
98/// "low", "moderate" or "high": such a label would be a property an author
99/// assigned, and presenting it beside measured values would lend it evidence it
100/// does not have. The way to learn an impact is to vary the decision and look,
101/// which is what an audit does and what [`MeasuredImpact`] records.
102#[derive(Clone, Copy, PartialEq, Debug, Default)]
103#[non_exhaustive]
104pub enum Impact {
105    /// Not measured. The honest default: nothing has been varied.
106    #[default]
107    Unmeasured,
108    /// The decision cannot change this result, for a structural reason (the
109    /// input has no alternate locations, say), not an estimated one.
110    Inert,
111    /// Measured by varying the decision across defensible alternatives.
112    Measured(MeasuredImpact),
113}
114
115/// The measured effect of one decision on one analysis.
116#[derive(Clone, Copy, PartialEq, Debug)]
117pub struct MeasuredImpact {
118    /// The distance used, such as `"jaccard-distance"` or `"relative-error"`.
119    pub metric: &'static str,
120    /// Mean distance between results that differ in this decision alone, in the
121    /// metric's own units.
122    pub change: f64,
123    /// How many pairs of results the measurement compared.
124    pub comparisons: u32,
125}
126
127/// Where a decision came from.
128#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
129#[non_exhaustive]
130pub enum AssumptionSource {
131    /// The caller set it.
132    Explicit,
133    /// It came from the profile.
134    ProfileDefault,
135    /// It was inferred from the data.
136    Inferred,
137}
138
139/// One decision made on the caller's behalf.
140#[derive(Clone, PartialEq, Debug)]
141pub struct Assumption {
142    /// Which decision.
143    pub field: PolicyField,
144    /// What was decided.
145    pub value: Box<str>,
146    /// Where the decision came from.
147    pub source: AssumptionSource,
148    /// How much it changed the answer, if that has been measured.
149    pub impact: Impact,
150}
151
152impl Assumption {
153    /// Records a decision.
154    #[must_use]
155    pub fn new(
156        field: PolicyField,
157        value: impl Into<Box<str>>,
158        source: AssumptionSource,
159        impact: Impact,
160    ) -> Self {
161        Self {
162            field,
163            value: value.into(),
164            source,
165            impact,
166        }
167    }
168
169    /// Returns true when the caller did not choose this and it measurably
170    /// changed the answer by at least `threshold`, in the metric's units.
171    ///
172    /// These are the assumptions worth surfacing: a default the caller never saw
173    /// that changed the answer is the failure this whole layer exists to expose.
174    /// An unmeasured impact is never material by this test; it is unknown.
175    #[must_use]
176    pub fn is_silent_and_material(&self, threshold: f64) -> bool {
177        self.source == AssumptionSource::ProfileDefault
178            && matches!(self.impact, Impact::Measured(measured) if measured.change >= threshold)
179    }
180}
181
182/// An outcome, and everything needed to judge it.
183///
184/// The answer is behind [`Analysis::outcome`], [`Analysis::value`] or
185/// [`Analysis::into_result`], each of which says what happens when there is no
186/// answer. There is no `Deref` to the value: a contract that lets the value be
187/// read without asking whether it exists is a contract the indeterminate case
188/// can slip through.
189///
190/// # Examples
191///
192/// ```
193/// use molframe_core::contract::{Analysis, AnalysisPolicy, Coverage, Status};
194///
195/// let policy = AnalysisPolicy::default();
196/// let radius = Analysis::complete(14.2_f64, Coverage::complete(1_960), &policy);
197///
198/// assert_eq!(radius.value(), Some(&14.2));
199/// assert_eq!(radius.status(), Status::Complete);
200/// assert_eq!(radius.coverage.fraction(), 1.0);
201/// ```
202#[derive(Clone, Debug)]
203pub struct Analysis<T> {
204    outcome: Outcome<T>,
205    quality: Quality,
206    /// How much of the intended data was used.
207    pub coverage: Coverage,
208    /// What the analysis found worth saying.
209    pub warnings: Vec<Diagnostic>,
210    /// What it decided on the caller's behalf.
211    pub assumptions: Vec<Assumption>,
212    /// Where the inputs came from and under what policy.
213    pub provenance: Provenance,
214    atom_origin: Option<Vec<u32>>,
215}
216
217impl<T> Analysis<T> {
218    /// An answer of the given quality.
219    #[must_use]
220    pub fn determinate(
221        value: T,
222        quality: Quality,
223        coverage: Coverage,
224        policy: &AnalysisPolicy,
225    ) -> Self {
226        Self::from_parts(
227            Outcome::Determinate(value),
228            quality,
229            coverage,
230            Vec::new(),
231            Vec::new(),
232            Provenance::new(policy),
233        )
234    }
235
236    /// A complete result.
237    #[must_use]
238    pub fn complete(value: T, coverage: Coverage, policy: &AnalysisPolicy) -> Self {
239        Self::determinate(value, Quality::Complete, coverage, policy)
240    }
241
242    /// A result computed over less than it intended.
243    #[must_use]
244    pub fn partial(value: T, coverage: Coverage, policy: &AnalysisPolicy) -> Self {
245        Self::determinate(value, Quality::Partial, coverage, policy)
246    }
247
248    /// A result that is one of several defensible ones.
249    #[must_use]
250    pub fn ambiguous(value: T, coverage: Coverage, policy: &AnalysisPolicy) -> Self {
251        Self::determinate(value, Quality::Ambiguous, coverage, policy)
252    }
253
254    /// A refusal to answer, with the reason.
255    ///
256    /// There is no value to supply and none to read back.
257    #[must_use]
258    pub fn indeterminate(
259        reason: Indeterminacy,
260        coverage: Coverage,
261        policy: &AnalysisPolicy,
262    ) -> Self {
263        Self::from_parts(
264            Outcome::Indeterminate(reason),
265            Quality::default(),
266            coverage,
267            Vec::new(),
268            Vec::new(),
269            Provenance::new(policy),
270        )
271    }
272
273    /// Assembles an analysis from its parts.
274    ///
275    /// `quality` qualifies a determinate outcome and is ignored for an
276    /// indeterminate one, which has no answer to qualify.
277    #[must_use]
278    pub const fn from_parts(
279        outcome: Outcome<T>,
280        quality: Quality,
281        coverage: Coverage,
282        warnings: Vec<Diagnostic>,
283        assumptions: Vec<Assumption>,
284        provenance: Provenance,
285    ) -> Self {
286        Self {
287            outcome,
288            quality,
289            coverage,
290            warnings,
291            assumptions,
292            provenance,
293            atom_origin: None,
294        }
295    }
296
297    /// Records which input atom each analysed atom is.
298    ///
299    /// A governed analysis runs over a system the policy builds: the input with some atoms
300    /// dropped (a conformer not chosen, hydrogens excluded) or copied (an assembly). The
301    /// atom indices in its result number that system, not the input; this map says which
302    /// input atom each one is, and copies of an atom share it.
303    #[must_use]
304    pub fn with_atom_origin(mut self, origin: Vec<u32>) -> Self {
305        self.atom_origin = Some(origin);
306        self
307    }
308
309    /// For each analysed atom, the input atom it is; `None` when the result's atoms are the
310    /// input's own.
311    #[must_use]
312    pub fn atom_origin(&self) -> Option<&[u32]> {
313        self.atom_origin.as_deref()
314    }
315
316    /// Takes the analysis apart, for code that builds a new one from an old one.
317    #[must_use]
318    pub fn into_parts(
319        self,
320    ) -> (
321        Outcome<T>,
322        Quality,
323        Coverage,
324        Vec<Diagnostic>,
325        Vec<Assumption>,
326        Provenance,
327    ) {
328        (
329            self.outcome,
330            self.quality,
331            self.coverage,
332            self.warnings,
333            self.assumptions,
334            self.provenance,
335        )
336    }
337
338    /// The answer or the reason there is none.
339    #[must_use]
340    pub const fn outcome(&self) -> &Outcome<T> {
341        &self.outcome
342    }
343
344    /// Consumes the analysis, keeping only the outcome.
345    #[must_use]
346    pub fn into_outcome(self) -> Outcome<T> {
347        self.outcome
348    }
349
350    /// The answer, if there is one.
351    #[must_use]
352    pub const fn value(&self) -> Option<&T> {
353        self.outcome.value()
354    }
355
356    /// The reason there is no answer, if there is none.
357    #[must_use]
358    pub const fn indeterminacy(&self) -> Option<&Indeterminacy> {
359        self.outcome.indeterminacy()
360    }
361
362    /// Consumes the analysis: the answer, or the reason it is missing.
363    ///
364    /// # Errors
365    ///
366    /// Returns the [`Indeterminacy`] when no defensible answer exists.
367    pub fn into_result(self) -> Result<T, Indeterminacy> {
368        self.outcome.into_result()
369    }
370
371    /// How far the analysis got: the quality of its answer, or `Indeterminate`.
372    #[must_use]
373    pub const fn status(&self) -> Status {
374        match self.outcome {
375            Outcome::Determinate(_) => match self.quality {
376                Quality::Complete => Status::Complete,
377                Quality::Partial => Status::Partial,
378                Quality::Ambiguous => Status::Ambiguous,
379            },
380            Outcome::Indeterminate(_) => Status::Indeterminate,
381        }
382    }
383
384    /// The quality of the answer, when there is one.
385    #[must_use]
386    pub const fn quality(&self) -> Option<Quality> {
387        match self.outcome {
388            Outcome::Determinate(_) => Some(self.quality),
389            Outcome::Indeterminate(_) => None,
390        }
391    }
392
393    /// Lowers the quality to `quality` if that is worse; no effect on a refusal.
394    pub fn degrade(&mut self, quality: Quality) {
395        self.quality = self.quality.worst(quality);
396    }
397
398    /// Attaches a finding.
399    #[must_use]
400    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
401        self.warnings.push(warning);
402        self
403    }
404
405    /// Records a decision made on the caller's behalf.
406    #[must_use]
407    pub fn with_assumption(mut self, assumption: Assumption) -> Self {
408        self.assumptions.push(assumption);
409        self
410    }
411
412    /// The fingerprint of the policy this was computed under.
413    #[must_use]
414    pub fn policy_fingerprint(&self) -> Fingerprint {
415        self.provenance.policy_fingerprint
416    }
417
418    /// The decisions the caller never made that measurably changed the answer by
419    /// at least `threshold`.
420    pub fn silent_assumptions(&self, threshold: f64) -> impl Iterator<Item = &Assumption> {
421        self.assumptions
422            .iter()
423            .filter(move |assumption| assumption.is_silent_and_material(threshold))
424    }
425
426    /// Applies a function to the answer, keeping everything else.
427    pub fn map<U>(self, transform: impl FnOnce(T) -> U) -> Analysis<U> {
428        Analysis {
429            outcome: self.outcome.map(transform),
430            quality: self.quality,
431            coverage: self.coverage,
432            warnings: self.warnings,
433            assumptions: self.assumptions,
434            provenance: self.provenance,
435            atom_origin: self.atom_origin,
436        }
437    }
438}
439
440#[cfg(test)]
441#[path = "analysis_tests.rs"]
442mod tests;