Skip to main content

molframe_core/contract/
provenance.rs

1//! Where a result came from.
2//!
3//! A record that travels with a number and says what produced it: which version
4//! of the software, which input, which policy, which reference data. It is what
5//! turns "we computed 247 contacts" into something a reader can check.
6//!
7//! Time is deliberately excluded from the fingerprint. Two identical analyses
8//! run a week apart must fingerprint identically, or the fingerprint identifies
9//! the run rather than the result.
10
11use super::digest::ContentDigest;
12use super::policy::{AnalysisPolicy, Fingerprint, PolicyField, ProfileId};
13use std::collections::BTreeMap;
14use std::fmt;
15use std::path::{Path, PathBuf};
16
17/// Where an input came from.
18#[derive(Clone, PartialEq, Eq, Debug, Default)]
19#[non_exhaustive]
20pub enum SourceRef {
21    /// Nothing was read.
22    #[default]
23    None,
24    /// A file on disk.
25    Path(PathBuf),
26    /// A network location.
27    Url(Box<str>),
28    /// Bytes the caller supplied.
29    Memory,
30}
31
32impl SourceRef {
33    /// Names a file.
34    #[must_use]
35    pub fn path(path: impl Into<PathBuf>) -> Self {
36        Self::Path(path.into())
37    }
38
39    /// The file this refers to, if it is a file.
40    #[must_use]
41    pub fn as_path(&self) -> Option<&Path> {
42        match self {
43            Self::Path(path) => Some(path),
44            _ => None,
45        }
46    }
47}
48
49impl fmt::Display for SourceRef {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::None => f.write_str("(none)"),
53            Self::Path(path) => write!(f, "{}", path.display()),
54            Self::Url(url) => f.write_str(url),
55            Self::Memory => f.write_str("(memory)"),
56        }
57    }
58}
59
60/// The version of a body of reference data.
61#[derive(Clone, PartialEq, Eq, Debug)]
62pub struct DictionaryVersion(Box<str>);
63
64impl DictionaryVersion {
65    /// Records a version.
66    #[must_use]
67    pub fn new(version: impl Into<Box<str>>) -> Self {
68        Self(version.into())
69    }
70
71    /// The version string.
72    #[must_use]
73    pub fn as_str(&self) -> &str {
74        &self.0
75    }
76}
77
78/// Stable name and version of the algorithm that produced a result.
79#[derive(Clone, PartialEq, Eq, Debug)]
80pub struct AlgorithmId {
81    name: Box<str>,
82    version: Box<str>,
83}
84
85impl AlgorithmId {
86    /// Creates a versioned algorithm identifier.
87    #[must_use]
88    pub fn new(name: impl Into<Box<str>>, version: impl Into<Box<str>>) -> Self {
89        Self {
90            name: name.into(),
91            version: version.into(),
92        }
93    }
94
95    /// Stable algorithm name.
96    #[must_use]
97    pub fn name(&self) -> &str {
98        &self.name
99    }
100
101    /// Algorithm version, independent of the crate release.
102    #[must_use]
103    pub fn version(&self) -> &str {
104        &self.version
105    }
106}
107
108/// A typed, deterministic result-affecting algorithm parameter.
109#[derive(Clone, PartialEq, Eq, Debug)]
110pub enum ParameterValue {
111    /// Boolean control.
112    Boolean(bool),
113    /// Signed integer control.
114    Integer(i64),
115    /// IEEE-754 value stored by its exact bits.
116    Float(u64),
117    /// Textual choice or identifier.
118    Text(Box<str>),
119}
120
121impl ParameterValue {
122    /// Records an exact finite floating-point parameter.
123    #[must_use]
124    pub fn finite_float(value: f64) -> Option<Self> {
125        value.is_finite().then_some(Self::Float(value.to_bits()))
126    }
127
128    pub(crate) fn serialised(&self) -> String {
129        match self {
130            Self::Boolean(value) => format!("bool:{value}"),
131            Self::Integer(value) => format!("int:{value}"),
132            Self::Float(bits) => format!("float:{bits:016x}"),
133            Self::Text(value) => format!("text:{value}"),
134        }
135    }
136}
137
138/// The reserved parameter that names the policy fields an analysis applied.
139const POLICY_READS: &str = "policy_reads";
140const ESTIMAND: &str = "estimand";
141
142/// Sorted algorithm parameters that participate in provenance fingerprints.
143pub type AnalysisParameters = BTreeMap<Box<str>, ParameterValue>;
144
145/// Everything needed to reproduce a result.
146///
147/// # Examples
148///
149/// ```
150/// use molframe_core::contract::{AnalysisPolicy, Provenance, SourceRef};
151///
152/// let policy = AnalysisPolicy::default();
153/// let record = Provenance::new(&policy).with_source(SourceRef::path("1abc.cif"));
154///
155/// assert_eq!(record.profile.map(|profile| profile.as_str()), Some("molframe-default-1.0"));
156/// assert_eq!(record.policy_fingerprint, policy.fingerprint());
157/// ```
158#[derive(Clone, Debug)]
159pub struct Provenance {
160    /// The version of molframe that produced the result.
161    pub molframe_version: &'static str,
162    /// Where the input came from.
163    pub input_source: SourceRef,
164    /// The SHA-256 digest of the input's bytes, where they were read.
165    pub input_digest: Option<ContentDigest>,
166    /// The policy in force.
167    pub policy: AnalysisPolicy,
168    /// A fingerprint of that policy.
169    pub policy_fingerprint: Fingerprint,
170    /// The named profile, where the policy is one unaltered.
171    pub profile: Option<ProfileId>,
172    /// Versioned algorithm, when the result is algorithm-specific.
173    pub algorithm: Option<AlgorithmId>,
174    /// Sorted typed parameters that can change the result.
175    pub parameters: AnalysisParameters,
176    /// The version of the schema dictionary consulted.
177    pub schema_version: Option<DictionaryVersion>,
178    /// The version of the chemical component data consulted.
179    pub component_version: Option<DictionaryVersion>,
180    /// When the analysis ran, if the caller asked for it recorded.
181    ///
182    /// Excluded from every fingerprint.
183    pub timestamp: Option<Box<str>>,
184}
185
186impl Provenance {
187    /// Starts a record under `policy`.
188    #[must_use]
189    pub fn new(policy: &AnalysisPolicy) -> Self {
190        Self {
191            molframe_version: env!("CARGO_PKG_VERSION"),
192            input_source: SourceRef::None,
193            input_digest: None,
194            policy: policy.clone(),
195            policy_fingerprint: policy.fingerprint(),
196            profile: policy.profile(),
197            algorithm: None,
198            parameters: BTreeMap::new(),
199            schema_version: None,
200            component_version: None,
201            timestamp: None,
202        }
203    }
204
205    /// Records which policy fields the analysis actually applied.
206    ///
207    /// A field the policy sets but the analysis never reads cannot change its
208    /// result, so varying it measures nothing. Recording what was read is what
209    /// lets an audit tell "stable under this decision" from "never saw this
210    /// decision".
211    #[must_use]
212    pub fn with_policy_reads(self, fields: &[PolicyField]) -> Self {
213        let mut names: Vec<&str> = fields.iter().map(|field| field.name()).collect();
214        names.sort_unstable();
215        names.dedup();
216        self.with_parameter(POLICY_READS, ParameterValue::Text(names.join(",").into()))
217    }
218
219    /// The policy fields the analysis applied, when it recorded them.
220    #[must_use]
221    pub fn policy_reads(&self) -> Option<Vec<PolicyField>> {
222        match self.parameters.get(POLICY_READS)? {
223            ParameterValue::Text(names) => Some(
224                names
225                    .split(',')
226                    .filter_map(PolicyField::from_name)
227                    .collect(),
228            ),
229            _ => None,
230        }
231    }
232
233    /// Records the quantity the analysis estimates, in words.
234    ///
235    /// Two analyses under the same name can ask different questions of the same
236    /// structure; saying which one was asked is part of what a result means.
237    #[must_use]
238    pub fn with_estimand(self, estimand: &str) -> Self {
239        self.with_parameter(ESTIMAND, ParameterValue::Text(estimand.into()))
240    }
241
242    /// The quantity the analysis estimates, when it said.
243    #[must_use]
244    pub fn estimand(&self) -> Option<&str> {
245        match self.parameters.get(ESTIMAND)? {
246            ParameterValue::Text(text) => Some(text),
247            _ => None,
248        }
249    }
250
251    /// Records where the input came from.
252    #[must_use]
253    pub fn with_source(mut self, source: SourceRef) -> Self {
254        self.input_source = source;
255        self
256    }
257
258    /// Records the SHA-256 digest of the input's bytes.
259    #[must_use]
260    pub const fn with_input_digest(mut self, digest: ContentDigest) -> Self {
261        self.input_digest = Some(digest);
262        self
263    }
264
265    /// Records when the analysis ran.
266    #[must_use]
267    pub fn with_timestamp(mut self, timestamp: impl Into<Box<str>>) -> Self {
268        self.timestamp = Some(timestamp.into());
269        self
270    }
271
272    /// Records the versioned algorithm used by this analysis.
273    #[must_use]
274    pub fn with_algorithm(mut self, algorithm: AlgorithmId) -> Self {
275        self.algorithm = Some(algorithm);
276        self
277    }
278
279    /// Records or replaces one result-affecting parameter.
280    #[must_use]
281    pub fn with_parameter(mut self, name: impl Into<Box<str>>, value: ParameterValue) -> Self {
282        self.parameters.insert(name.into(), value);
283        self
284    }
285
286    /// A fingerprint of everything that affects the result.
287    ///
288    /// Two runs over the same input under the same policy agree here whatever
289    /// the clock said, which is what makes the value a property of the result
290    /// rather than of the occasion.
291    #[must_use]
292    pub fn fingerprint(&self) -> Fingerprint {
293        let mut bytes = Vec::with_capacity(64);
294        bytes.extend_from_slice(self.molframe_version.as_bytes());
295        bytes.extend_from_slice(&self.policy_fingerprint.get().to_le_bytes());
296        if let Some(input) = self.input_digest {
297            bytes.extend_from_slice(input.as_bytes());
298        }
299        if let Some(version) = &self.schema_version {
300            bytes.extend_from_slice(version.as_str().as_bytes());
301        }
302        if let Some(version) = &self.component_version {
303            bytes.extend_from_slice(version.as_str().as_bytes());
304        }
305        if let Some(algorithm) = &self.algorithm {
306            fingerprint_field(&mut bytes, b"algorithm.name", algorithm.name());
307            fingerprint_field(&mut bytes, b"algorithm.version", algorithm.version());
308        }
309        for (name, value) in &self.parameters {
310            fingerprint_field(&mut bytes, name.as_bytes(), &value.serialised());
311        }
312        Fingerprint::of(&bytes)
313    }
314}
315
316fn fingerprint_field(bytes: &mut Vec<u8>, name: &[u8], value: &str) {
317    bytes.extend_from_slice(&(name.len() as u64).to_le_bytes());
318    bytes.extend_from_slice(name);
319    bytes.extend_from_slice(&(value.len() as u64).to_le_bytes());
320    bytes.extend_from_slice(value.as_bytes());
321}
322
323#[cfg(test)]
324#[path = "provenance_tests.rs"]
325mod tests;