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;