Skip to main content

scientific_workflow/
rng_record.rs

1//! Lightweight provenance records for application-owned random number generators.
2//!
3//! Workflow does not generate keys, derive streams, select algorithms, sample
4//! distributions, or maintain RNG cursors. Applications perform those tasks
5//! and use [`RngRecord`] only to persist enough exact identity for
6//! reproducibility and continuation validation.
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use thiserror::Error;
11
12/// Reserved user-metadata key containing records indexed by application namespace.
13pub const RNG_RECORDS_METADATA_KEY: &str = "rng_records";
14
15/// Immutable identity of one application-owned random source.
16///
17/// Keys are persisted as plain text and therefore must be reproducibility
18/// material rather than secrets. `method` and `version` should identify every
19/// implementation detail that affects the produced sequence, including a
20/// distribution transform when relevant. When an upstream scientific API
21/// accepts optional RNG settings, copy its resolved method and seed here rather
22/// than recording the unresolved request.
23#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct RngRecord {
26    namespace: String,
27    method: String,
28    version: String,
29    key_encoding: String,
30    key: String,
31    #[serde(default, skip_serializing_if = "Map::is_empty")]
32    parameters: Map<String, Value>,
33}
34
35impl RngRecord {
36    /// Creates and validates one application-namespaced RNG record.
37    pub fn new(
38        namespace: impl Into<String>,
39        method: impl Into<String>,
40        version: impl Into<String>,
41        key_encoding: impl Into<String>,
42        key: impl Into<String>,
43        parameters: Option<Map<String, Value>>,
44    ) -> Result<Self, RngRecordError> {
45        let record = Self {
46            namespace: namespace.into(),
47            method: method.into(),
48            version: version.into(),
49            key_encoding: key_encoding.into(),
50            key: key.into(),
51            parameters: parameters.unwrap_or_default(),
52        };
53        record.validate()?;
54        Ok(record)
55    }
56
57    /// Returns the collision domain used inside recording metadata.
58    pub fn namespace(&self) -> &str {
59        &self.namespace
60    }
61
62    /// Returns the application-declared RNG or sampling method.
63    pub fn method(&self) -> &str {
64        &self.method
65    }
66
67    /// Returns the application-declared sequence-affecting version.
68    pub fn version(&self) -> &str {
69        &self.version
70    }
71
72    /// Returns the application-declared key representation.
73    pub fn key_encoding(&self) -> &str {
74        &self.key_encoding
75    }
76
77    /// Returns the persisted reproducibility key.
78    pub fn key(&self) -> &str {
79        &self.key
80    }
81
82    /// Borrows opaque application-defined method parameters.
83    pub const fn parameters(&self) -> &Map<String, Value> {
84        &self.parameters
85    }
86
87    /// Inserts this record beneath the reserved user-metadata object.
88    ///
89    /// Existing unrelated metadata is preserved. Reusing a namespace is
90    /// rejected rather than overwritten.
91    pub fn insert_into_metadata(
92        &self,
93        metadata: &mut Map<String, Value>,
94    ) -> Result<(), RngRecordError> {
95        self.validate()?;
96        let records = metadata
97            .entry(RNG_RECORDS_METADATA_KEY.to_owned())
98            .or_insert_with(|| Value::Object(Map::new()))
99            .as_object_mut()
100            .ok_or(RngRecordError::InvalidMetadataShape)?;
101        if records.contains_key(&self.namespace) {
102            return Err(RngRecordError::DuplicateNamespace {
103                namespace: self.namespace.clone(),
104            });
105        }
106        records.insert(
107            self.namespace.clone(),
108            serde_json::to_value(self).expect("RNG records contain only JSON-compatible values"),
109        );
110        Ok(())
111    }
112
113    /// Reads and validates one namespaced record from user metadata.
114    pub fn from_metadata(
115        metadata: &Map<String, Value>,
116        namespace: &str,
117    ) -> Result<Option<Self>, RngRecordError> {
118        let Some(value) = metadata.get(RNG_RECORDS_METADATA_KEY) else {
119            return Ok(None);
120        };
121        let records = value
122            .as_object()
123            .ok_or(RngRecordError::InvalidMetadataShape)?;
124        let Some(value) = records.get(namespace) else {
125            return Ok(None);
126        };
127        let record: Self = serde_json::from_value(value.clone()).map_err(|source| {
128            RngRecordError::InvalidStoredRecord {
129                namespace: namespace.to_owned(),
130                source,
131            }
132        })?;
133        record.validate()?;
134        if record.namespace != namespace {
135            return Err(RngRecordError::NamespaceMismatch {
136                index: namespace.to_owned(),
137                record: record.namespace,
138            });
139        }
140        Ok(Some(record))
141    }
142
143    fn validate(&self) -> Result<(), RngRecordError> {
144        for (field, value) in [
145            ("namespace", self.namespace.as_str()),
146            ("method", self.method.as_str()),
147            ("version", self.version.as_str()),
148            ("key_encoding", self.key_encoding.as_str()),
149            ("key", self.key.as_str()),
150        ] {
151            if value.trim().is_empty() {
152                return Err(RngRecordError::EmptyField { field });
153            }
154        }
155        Ok(())
156    }
157}
158
159/// Rejection while constructing or embedding RNG provenance.
160#[derive(Debug, Error)]
161#[non_exhaustive]
162pub enum RngRecordError {
163    /// A required textual identity field is empty or whitespace-only.
164    #[error("RNG record field `{field}` must not be empty")]
165    EmptyField {
166        /// Rejected stable field name.
167        field: &'static str,
168    },
169    /// A metadata map already contains a record for the namespace.
170    #[error("RNG namespace `{namespace}` is recorded more than once")]
171    DuplicateNamespace {
172        /// Repeated application namespace.
173        namespace: String,
174    },
175    /// The reserved metadata entry is not an object indexed by namespace.
176    #[error("user metadata `{RNG_RECORDS_METADATA_KEY}` entry must be an object")]
177    InvalidMetadataShape,
178    /// A stored namespaced value does not decode as an RNG record.
179    #[error("invalid RNG record for namespace `{namespace}`")]
180    InvalidStoredRecord {
181        /// Namespace selected by the caller.
182        namespace: String,
183        /// JSON type or field failure.
184        #[source]
185        source: serde_json::Error,
186    },
187    /// The metadata index and embedded record namespace disagree.
188    #[error("RNG metadata index `{index}` contains record namespace `{record}`")]
189    NamespaceMismatch {
190        /// Namespace used as the metadata object key.
191        index: String,
192        /// Namespace embedded in the record.
193        record: String,
194    },
195}