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.
11
12use super::policy::{AnalysisPolicy, Fingerprint, PolicyField};
13use super::provenance::Provenance;
14use crate::diagnostic::Diagnostic;
15use std::ops::Deref;
16
17/// How far an analysis got.
18#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
19#[non_exhaustive]
20pub enum Status {
21    /// Every intended atom was available and unambiguous.
22    #[default]
23    Complete,
24    /// Computed, but some intended atoms were not available.
25    Partial,
26    /// Several defensible answers exist; one was chosen deterministically.
27    Ambiguous,
28    /// No defensible single answer exists under this policy.
29    ///
30    /// The value carried alongside this status is not meaningful.
31    Indeterminate,
32}
33
34impl Status {
35    /// Returns true when the value may be used as an answer.
36    #[must_use]
37    pub const fn is_usable(self) -> bool {
38        !matches!(self, Self::Indeterminate)
39    }
40}
41
42/// How much of what an analysis meant to use it actually used.
43///
44/// "Intended" comes from what a component should contain, not from what the
45/// file happened to have — so a residue whose side chain was never modelled
46/// reduces coverage rather than silently shrinking the calculation.
47#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
48pub struct Coverage {
49    /// Atoms the analysis meant to use.
50    pub intended: u32,
51    /// Atoms it did use.
52    pub used: u32,
53    /// Atoms absent from the model.
54    pub missing: u32,
55    /// Atoms present but not uniquely resolvable.
56    pub ambiguous: u32,
57}
58
59impl Coverage {
60    /// Coverage for an analysis that used everything it meant to.
61    #[must_use]
62    pub const fn complete(atoms: u32) -> Self {
63        Self {
64            intended: atoms,
65            used: atoms,
66            missing: 0,
67            ambiguous: 0,
68        }
69    }
70
71    /// The fraction of intended atoms that were used.
72    ///
73    /// An analysis over two fifths of the intended atoms is not the same
74    /// quantity as one over all of them, and this is what says so.
75    ///
76    /// # Panics
77    /// The checked conversion accepts every unsigned 32-bit count.
78    #[must_use]
79    pub fn fraction(self) -> f64 {
80        if self.intended == 0 {
81            1.0
82        } else {
83            f64::from(self.used) / f64::from(self.intended)
84        }
85    }
86
87    /// Returns true when nothing was missing or ambiguous.
88    #[must_use]
89    pub const fn is_complete(self) -> bool {
90        self.missing == 0 && self.ambiguous == 0 && self.used == self.intended
91    }
92}
93
94/// How much a decision could have changed the answer.
95#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
96#[non_exhaustive]
97pub enum ImpactEstimate {
98    /// The decision could not have changed anything here.
99    None,
100    /// Unlikely to matter.
101    Low,
102    /// Could matter.
103    Moderate,
104    /// Likely to matter.
105    High,
106    /// Not estimated.
107    #[default]
108    Unknown,
109}
110
111/// Where a decision came from.
112#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
113#[non_exhaustive]
114pub enum AssumptionSource {
115    /// The caller set it.
116    Explicit,
117    /// It came from the profile.
118    ProfileDefault,
119    /// It was inferred from the data.
120    Inferred,
121}
122
123/// One decision made on the caller's behalf.
124#[derive(Clone, PartialEq, Debug)]
125pub struct Assumption {
126    /// Which decision.
127    pub field: PolicyField,
128    /// What was decided.
129    pub value: Box<str>,
130    /// Where the decision came from.
131    pub source: AssumptionSource,
132    /// How much it could have mattered.
133    pub impact: ImpactEstimate,
134}
135
136impl Assumption {
137    /// Records a decision.
138    #[must_use]
139    pub fn new(
140        field: PolicyField,
141        value: impl Into<Box<str>>,
142        source: AssumptionSource,
143        impact: ImpactEstimate,
144    ) -> Self {
145        Self {
146            field,
147            value: value.into(),
148            source,
149            impact,
150        }
151    }
152
153    /// Returns true when the caller did not choose this and it could matter.
154    ///
155    /// These are the assumptions worth surfacing: a default the caller never saw
156    /// that changed the answer is the failure this whole layer exists to expose.
157    #[must_use]
158    pub fn is_silent_and_material(&self) -> bool {
159        self.source == AssumptionSource::ProfileDefault
160            && self.impact >= ImpactEstimate::Moderate
161            && self.impact != ImpactEstimate::Unknown
162    }
163}
164
165/// A value, and everything needed to judge it.
166///
167/// Dereferences to the value, so reading the answer costs one character and
168/// reading the rest is there when it matters. Ergonomics are load-bearing here:
169/// a contract that makes ordinary work painful gets bypassed, and a bypassed
170/// contract records nothing.
171///
172/// # Examples
173///
174/// ```
175/// use molframe_core::contract::{Analysis, AnalysisPolicy, Coverage, Status};
176///
177/// let policy = AnalysisPolicy::default();
178/// let radius = Analysis::complete(14.2_f64, Coverage::complete(1_960), &policy);
179///
180/// assert_eq!(*radius, 14.2);
181/// assert_eq!(radius.status, Status::Complete);
182/// assert_eq!(radius.coverage.fraction(), 1.0);
183/// ```
184#[derive(Clone, Debug)]
185pub struct Analysis<T> {
186    /// The answer.
187    pub value: T,
188    /// How far the analysis got.
189    pub status: Status,
190    /// How much of the intended data was used.
191    pub coverage: Coverage,
192    /// What the analysis found worth saying.
193    pub warnings: Vec<Diagnostic>,
194    /// What it decided on the caller's behalf.
195    pub assumptions: Vec<Assumption>,
196    /// Where the inputs came from and under what policy.
197    pub provenance: Provenance,
198}
199
200impl<T> Analysis<T> {
201    /// A complete result.
202    #[must_use]
203    pub fn complete(value: T, coverage: Coverage, policy: &AnalysisPolicy) -> Self {
204        Self {
205            value,
206            status: Status::Complete,
207            coverage,
208            warnings: Vec::new(),
209            assumptions: Vec::new(),
210            provenance: Provenance::new(policy),
211        }
212    }
213
214    /// A result computed over less than it intended.
215    #[must_use]
216    pub fn partial(value: T, coverage: Coverage, policy: &AnalysisPolicy) -> Self {
217        Self {
218            status: Status::Partial,
219            ..Self::complete(value, coverage, policy)
220        }
221    }
222
223    /// A refusal to answer.
224    ///
225    /// The value is whatever stands in for "nothing"; the status is the result.
226    #[must_use]
227    pub fn indeterminate(value: T, coverage: Coverage, policy: &AnalysisPolicy) -> Self {
228        Self {
229            status: Status::Indeterminate,
230            ..Self::complete(value, coverage, policy)
231        }
232    }
233
234    /// Attaches a finding.
235    #[must_use]
236    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
237        self.warnings.push(warning);
238        self
239    }
240
241    /// Records a decision made on the caller's behalf.
242    #[must_use]
243    pub fn with_assumption(mut self, assumption: Assumption) -> Self {
244        self.assumptions.push(assumption);
245        self
246    }
247
248    /// The fingerprint of the policy this was computed under.
249    #[must_use]
250    pub fn policy_fingerprint(&self) -> Fingerprint {
251        self.provenance.policy_fingerprint
252    }
253
254    /// The decisions the caller never made that could have changed the answer.
255    pub fn silent_assumptions(&self) -> impl Iterator<Item = &Assumption> {
256        self.assumptions
257            .iter()
258            .filter(|assumption| assumption.is_silent_and_material())
259    }
260
261    /// Applies a function to the value, keeping everything else.
262    pub fn map<U>(self, transform: impl FnOnce(T) -> U) -> Analysis<U> {
263        Analysis {
264            value: transform(self.value),
265            status: self.status,
266            coverage: self.coverage,
267            warnings: self.warnings,
268            assumptions: self.assumptions,
269            provenance: self.provenance,
270        }
271    }
272}
273
274impl<T> Deref for Analysis<T> {
275    type Target = T;
276
277    fn deref(&self) -> &T {
278        &self.value
279    }
280}
281
282#[cfg(test)]
283#[path = "analysis_tests.rs"]
284mod tests;