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::policy::{AnalysisPolicy, Fingerprint, ProfileId};
12use std::collections::BTreeMap;
13use std::fmt;
14use std::path::{Path, PathBuf};
15
16/// Where an input came from.
17#[derive(Clone, PartialEq, Eq, Debug, Default)]
18#[non_exhaustive]
19pub enum SourceRef {
20    /// Nothing was read.
21    #[default]
22    None,
23    /// A file on disk.
24    Path(PathBuf),
25    /// A network location.
26    Url(Box<str>),
27    /// Bytes the caller supplied.
28    Memory,
29}
30
31impl SourceRef {
32    /// Names a file.
33    #[must_use]
34    pub fn path(path: impl Into<PathBuf>) -> Self {
35        Self::Path(path.into())
36    }
37
38    /// The file this refers to, if it is a file.
39    #[must_use]
40    pub fn as_path(&self) -> Option<&Path> {
41        match self {
42            Self::Path(path) => Some(path),
43            _ => None,
44        }
45    }
46}
47
48impl fmt::Display for SourceRef {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::None => f.write_str("(none)"),
52            Self::Path(path) => write!(f, "{}", path.display()),
53            Self::Url(url) => f.write_str(url),
54            Self::Memory => f.write_str("(memory)"),
55        }
56    }
57}
58
59/// The version of a body of reference data.
60#[derive(Clone, PartialEq, Eq, Debug)]
61pub struct DictionaryVersion(Box<str>);
62
63impl DictionaryVersion {
64    /// Records a version.
65    #[must_use]
66    pub fn new(version: impl Into<Box<str>>) -> Self {
67        Self(version.into())
68    }
69
70    /// The version string.
71    #[must_use]
72    pub fn as_str(&self) -> &str {
73        &self.0
74    }
75}
76
77/// Stable name and version of the algorithm that produced a result.
78#[derive(Clone, PartialEq, Eq, Debug)]
79pub struct AlgorithmId {
80    name: Box<str>,
81    version: Box<str>,
82}
83
84impl AlgorithmId {
85    /// Creates a versioned algorithm identifier.
86    #[must_use]
87    pub fn new(name: impl Into<Box<str>>, version: impl Into<Box<str>>) -> Self {
88        Self {
89            name: name.into(),
90            version: version.into(),
91        }
92    }
93
94    /// Stable algorithm name.
95    #[must_use]
96    pub fn name(&self) -> &str {
97        &self.name
98    }
99
100    /// Algorithm version, independent of the crate release.
101    #[must_use]
102    pub fn version(&self) -> &str {
103        &self.version
104    }
105}
106
107/// A typed, deterministic result-affecting algorithm parameter.
108#[derive(Clone, PartialEq, Eq, Debug)]
109pub enum ParameterValue {
110    /// Boolean control.
111    Boolean(bool),
112    /// Signed integer control.
113    Integer(i64),
114    /// IEEE-754 value stored by its exact bits.
115    Float(u64),
116    /// Textual choice or identifier.
117    Text(Box<str>),
118}
119
120impl ParameterValue {
121    /// Records an exact finite floating-point parameter.
122    #[must_use]
123    pub fn finite_float(value: f64) -> Option<Self> {
124        value.is_finite().then_some(Self::Float(value.to_bits()))
125    }
126
127    pub(crate) fn serialised(&self) -> String {
128        match self {
129            Self::Boolean(value) => format!("bool:{value}"),
130            Self::Integer(value) => format!("int:{value}"),
131            Self::Float(bits) => format!("float:{bits:016x}"),
132            Self::Text(value) => format!("text:{value}"),
133        }
134    }
135}
136
137/// Sorted algorithm parameters that participate in provenance fingerprints.
138pub type AnalysisParameters = BTreeMap<Box<str>, ParameterValue>;
139
140/// Everything needed to reproduce a result.
141///
142/// # Examples
143///
144/// ```
145/// use molframe_core::contract::{AnalysisPolicy, Provenance, SourceRef};
146///
147/// let policy = AnalysisPolicy::default();
148/// let record = Provenance::new(&policy).with_source(SourceRef::path("1abc.cif"));
149///
150/// assert_eq!(record.profile.map(|profile| profile.as_str()), Some("molframe-default-1.0"));
151/// assert_eq!(record.policy_fingerprint, policy.fingerprint());
152/// ```
153#[derive(Clone, Debug)]
154pub struct Provenance {
155    /// The version of molframe that produced the result.
156    pub molframe_version: &'static str,
157    /// Where the input came from.
158    pub input_source: SourceRef,
159    /// A fingerprint of the input's bytes, where they were read.
160    pub input_fingerprint: Option<Fingerprint>,
161    /// The policy in force.
162    pub policy: AnalysisPolicy,
163    /// A fingerprint of that policy.
164    pub policy_fingerprint: Fingerprint,
165    /// The named profile, where the policy is one unaltered.
166    pub profile: Option<ProfileId>,
167    /// Versioned algorithm, when the result is algorithm-specific.
168    pub algorithm: Option<AlgorithmId>,
169    /// Sorted typed parameters that can change the result.
170    pub parameters: AnalysisParameters,
171    /// The version of the schema dictionary consulted.
172    pub schema_version: Option<DictionaryVersion>,
173    /// The version of the chemical component data consulted.
174    pub component_version: Option<DictionaryVersion>,
175    /// When the analysis ran, if the caller asked for it recorded.
176    ///
177    /// Excluded from every fingerprint.
178    pub timestamp: Option<Box<str>>,
179}
180
181impl Provenance {
182    /// Starts a record under `policy`.
183    #[must_use]
184    pub fn new(policy: &AnalysisPolicy) -> Self {
185        Self {
186            molframe_version: env!("CARGO_PKG_VERSION"),
187            input_source: SourceRef::None,
188            input_fingerprint: None,
189            policy: policy.clone(),
190            policy_fingerprint: policy.fingerprint(),
191            profile: policy.profile(),
192            algorithm: None,
193            parameters: BTreeMap::new(),
194            schema_version: None,
195            component_version: None,
196            timestamp: None,
197        }
198    }
199
200    /// Records where the input came from.
201    #[must_use]
202    pub fn with_source(mut self, source: SourceRef) -> Self {
203        self.input_source = source;
204        self
205    }
206
207    /// Records a fingerprint of the input's bytes.
208    #[must_use]
209    pub const fn with_input_fingerprint(mut self, fingerprint: Fingerprint) -> Self {
210        self.input_fingerprint = Some(fingerprint);
211        self
212    }
213
214    /// Records when the analysis ran.
215    #[must_use]
216    pub fn with_timestamp(mut self, timestamp: impl Into<Box<str>>) -> Self {
217        self.timestamp = Some(timestamp.into());
218        self
219    }
220
221    /// Records the versioned algorithm used by this analysis.
222    #[must_use]
223    pub fn with_algorithm(mut self, algorithm: AlgorithmId) -> Self {
224        self.algorithm = Some(algorithm);
225        self
226    }
227
228    /// Records or replaces one result-affecting parameter.
229    #[must_use]
230    pub fn with_parameter(mut self, name: impl Into<Box<str>>, value: ParameterValue) -> Self {
231        self.parameters.insert(name.into(), value);
232        self
233    }
234
235    /// A fingerprint of everything that affects the result.
236    ///
237    /// Two runs over the same input under the same policy agree here whatever
238    /// the clock said, which is what makes the value a property of the result
239    /// rather than of the occasion.
240    #[must_use]
241    pub fn fingerprint(&self) -> Fingerprint {
242        let mut bytes = Vec::with_capacity(64);
243        bytes.extend_from_slice(self.molframe_version.as_bytes());
244        bytes.extend_from_slice(&self.policy_fingerprint.get().to_le_bytes());
245        if let Some(input) = self.input_fingerprint {
246            bytes.extend_from_slice(&input.get().to_le_bytes());
247        }
248        if let Some(version) = &self.schema_version {
249            bytes.extend_from_slice(version.as_str().as_bytes());
250        }
251        if let Some(version) = &self.component_version {
252            bytes.extend_from_slice(version.as_str().as_bytes());
253        }
254        if let Some(algorithm) = &self.algorithm {
255            fingerprint_field(&mut bytes, b"algorithm.name", algorithm.name());
256            fingerprint_field(&mut bytes, b"algorithm.version", algorithm.version());
257        }
258        for (name, value) in &self.parameters {
259            fingerprint_field(&mut bytes, name.as_bytes(), &value.serialised());
260        }
261        Fingerprint::of(&bytes)
262    }
263}
264
265fn fingerprint_field(bytes: &mut Vec<u8>, name: &[u8], value: &str) {
266    bytes.extend_from_slice(&(name.len() as u64).to_le_bytes());
267    bytes.extend_from_slice(name);
268    bytes.extend_from_slice(&(value.len() as u64).to_le_bytes());
269    bytes.extend_from_slice(value.as_bytes());
270}
271
272#[cfg(test)]
273#[path = "provenance_tests.rs"]
274mod tests;