Skip to main content

scientific_workflow/
rng_record.rs

1//! Deterministic replicate seeds and provenance for application-owned RNGs.
2//!
3//! [`ReplicateSeedDeriver`] lazily derives a stable named `u64` seed from one
4//! study base seed and replicate index. The result includes an [`RngRecord`]
5//! ready for storage provenance. Workflow does not construct RNG engines,
6//! sample distributions, or maintain RNG cursors.
7//!
8//! # Boundary
9//!
10//! This module owns one versioned seed-derivation algorithm plus the metadata
11//! schema and deduplication rules for RNG records. Callers retain control of RNG
12//! construction, sampling, and state restoration order.
13
14use sha2::{Digest, Sha256};
15
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use thiserror::Error;
19
20/// Reserved user-metadata key containing records indexed by application namespace.
21pub const RNG_RECORDS_METADATA_KEY: &str = "rng_records";
22
23const REPLICATE_SEED_METHOD: &str = "sha256-domain-separated";
24const REPLICATE_SEED_VERSION: &str = "scientific-workflow.replicate-seed.v1";
25
26/// Lazy source of deterministic, order-independent named replicate seeds.
27///
28/// A namespace identifies one scientific random stream, such as `matrix`,
29/// `pairing`, or `replacement/task-17`. The same base seed, replicate index,
30/// and namespace always produce the same value regardless of process,
31/// scheduling, or request order. Different namespaces are domain-separated.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct ReplicateSeedDeriver {
34    base_seed: u64,
35    replicate_index: u64,
36}
37
38/// One derived seed together with its exact persistable provenance record.
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct DerivedSeed {
41    value: u64,
42    record: RngRecord,
43}
44
45impl ReplicateSeedDeriver {
46    /// Creates a lazy deriver for one replicate.
47    pub const fn new(base_seed: u64, replicate_index: u64) -> Self {
48        Self {
49            base_seed,
50            replicate_index,
51        }
52    }
53
54    /// Returns the study-level base seed.
55    pub const fn base_seed(self) -> u64 {
56        self.base_seed
57    }
58
59    /// Returns the zero-based replicate index.
60    pub const fn replicate_index(self) -> u64 {
61        self.replicate_index
62    }
63
64    /// Derives one named stream seed and its matching provenance record.
65    ///
66    /// Derivation occurs only when requested. The namespace must be nonblank
67    /// and should remain stable across executions. The versioned byte contract
68    /// is SHA-256 over the method version, base seed, replicate index, namespace
69    /// byte length, and namespace bytes; the first eight digest bytes are read
70    /// as a big-endian `u64`.
71    pub fn derive(self, namespace: &str) -> Result<DerivedSeed, RngRecordError> {
72        if namespace.trim().is_empty() {
73            return Err(RngRecordError::EmptySeedNamespace);
74        }
75        let namespace_length =
76            u64::try_from(namespace.len()).map_err(|_| RngRecordError::SeedNamespaceTooLong)?;
77        let mut hasher = Sha256::new();
78        hasher.update(REPLICATE_SEED_VERSION.as_bytes());
79        hasher.update([0]);
80        hasher.update(self.base_seed.to_be_bytes());
81        hasher.update(self.replicate_index.to_be_bytes());
82        hasher.update(namespace_length.to_be_bytes());
83        hasher.update(namespace.as_bytes());
84        let digest = hasher.finalize();
85        let value = u64::from_be_bytes(
86            digest[..8]
87                .try_into()
88                .expect("SHA-256 always contains at least eight bytes"),
89        );
90        let parameters = Map::from_iter([
91            ("base_seed".to_owned(), Value::from(self.base_seed)),
92            (
93                "replicate_index".to_owned(),
94                Value::from(self.replicate_index),
95            ),
96        ]);
97        let record = RngRecord::new(
98            namespace,
99            REPLICATE_SEED_METHOD,
100            REPLICATE_SEED_VERSION,
101            "u64-decimal",
102            value.to_string(),
103            Some(parameters),
104        )?;
105        Ok(DerivedSeed { value, record })
106    }
107}
108
109impl DerivedSeed {
110    /// Returns the derived `u64` suitable for application RNG construction.
111    pub const fn value(&self) -> u64 {
112        self.value
113    }
114
115    /// Borrows the exact record that should accompany persisted RNG output.
116    pub const fn record(&self) -> &RngRecord {
117        &self.record
118    }
119
120    /// Splits the result into its seed and owned provenance record.
121    pub fn into_parts(self) -> (u64, RngRecord) {
122        (self.value, self.record)
123    }
124}
125
126/// Immutable identity of one application-owned random source.
127///
128/// Keys are persisted as plain text and therefore must be reproducibility
129/// material rather than secrets. `method` and `version` should identify every
130/// implementation detail that affects the produced sequence, including a
131/// distribution transform when relevant. When an upstream scientific API
132/// accepts optional RNG settings, copy its resolved method and seed here rather
133/// than recording the unresolved request.
134#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
135#[serde(deny_unknown_fields)]
136pub struct RngRecord {
137    namespace: String,
138    method: String,
139    version: String,
140    key_encoding: String,
141    key: String,
142    #[serde(default, skip_serializing_if = "Map::is_empty")]
143    parameters: Map<String, Value>,
144}
145
146impl RngRecord {
147    /// Creates and validates one application-namespaced RNG record.
148    pub fn new(
149        namespace: impl Into<String>,
150        method: impl Into<String>,
151        version: impl Into<String>,
152        key_encoding: impl Into<String>,
153        key: impl Into<String>,
154        parameters: Option<Map<String, Value>>,
155    ) -> Result<Self, RngRecordError> {
156        let record = Self {
157            namespace: namespace.into(),
158            method: method.into(),
159            version: version.into(),
160            key_encoding: key_encoding.into(),
161            key: key.into(),
162            parameters: parameters.unwrap_or_default(),
163        };
164        record.validate()?;
165        Ok(record)
166    }
167
168    /// Returns the collision domain used inside recording metadata.
169    pub fn namespace(&self) -> &str {
170        &self.namespace
171    }
172
173    /// Returns the application-declared RNG or sampling method.
174    pub fn method(&self) -> &str {
175        &self.method
176    }
177
178    /// Returns the application-declared sequence-affecting version.
179    pub fn version(&self) -> &str {
180        &self.version
181    }
182
183    /// Returns the application-declared key representation.
184    pub fn key_encoding(&self) -> &str {
185        &self.key_encoding
186    }
187
188    /// Returns the persisted reproducibility key.
189    pub fn key(&self) -> &str {
190        &self.key
191    }
192
193    /// Borrows opaque application-defined method parameters.
194    pub const fn parameters(&self) -> &Map<String, Value> {
195        &self.parameters
196    }
197
198    /// Inserts this record beneath the reserved user-metadata object.
199    ///
200    /// Existing unrelated metadata is preserved. Reusing a namespace is
201    /// rejected rather than overwritten.
202    pub fn insert_into_metadata(
203        &self,
204        metadata: &mut Map<String, Value>,
205    ) -> Result<(), RngRecordError> {
206        self.validate()?;
207        let records = metadata
208            .entry(RNG_RECORDS_METADATA_KEY.to_owned())
209            .or_insert_with(|| Value::Object(Map::new()))
210            .as_object_mut()
211            .ok_or(RngRecordError::InvalidMetadataShape)?;
212        if records.contains_key(&self.namespace) {
213            return Err(RngRecordError::DuplicateNamespace {
214                namespace: self.namespace.clone(),
215            });
216        }
217        records.insert(
218            self.namespace.clone(),
219            serde_json::to_value(self).expect("RNG records contain only JSON-compatible values"),
220        );
221        Ok(())
222    }
223
224    /// Reads and validates one namespaced record from user metadata.
225    pub fn from_metadata(
226        metadata: &Map<String, Value>,
227        namespace: &str,
228    ) -> Result<Option<Self>, RngRecordError> {
229        let Some(value) = metadata.get(RNG_RECORDS_METADATA_KEY) else {
230            return Ok(None);
231        };
232        let records = value
233            .as_object()
234            .ok_or(RngRecordError::InvalidMetadataShape)?;
235        let Some(value) = records.get(namespace) else {
236            return Ok(None);
237        };
238        let record: Self = serde_json::from_value(value.clone()).map_err(|source| {
239            RngRecordError::InvalidStoredRecord {
240                namespace: namespace.to_owned(),
241                source,
242            }
243        })?;
244        record.validate()?;
245        if record.namespace != namespace {
246            return Err(RngRecordError::NamespaceMismatch {
247                index: namespace.to_owned(),
248                record: record.namespace,
249            });
250        }
251        Ok(Some(record))
252    }
253
254    fn validate(&self) -> Result<(), RngRecordError> {
255        for (field, value) in [
256            ("namespace", self.namespace.as_str()),
257            ("method", self.method.as_str()),
258            ("version", self.version.as_str()),
259            ("key_encoding", self.key_encoding.as_str()),
260            ("key", self.key.as_str()),
261        ] {
262            if value.trim().is_empty() {
263                return Err(RngRecordError::EmptyField { field });
264            }
265        }
266        Ok(())
267    }
268}
269
270/// Rejection while constructing or embedding RNG provenance.
271#[derive(Debug, Error)]
272#[non_exhaustive]
273pub enum RngRecordError {
274    /// A requested replicate stream has no stable identity.
275    #[error("replicate seed namespace must not be empty or whitespace-only")]
276    EmptySeedNamespace,
277    /// A namespace cannot be represented by the versioned length encoding.
278    #[error("replicate seed namespace is too long")]
279    SeedNamespaceTooLong,
280    /// A required textual identity field is empty or whitespace-only.
281    #[error("RNG record field `{field}` must not be empty")]
282    EmptyField {
283        /// Rejected stable field name.
284        field: &'static str,
285    },
286    /// A metadata map already contains a record for the namespace.
287    #[error("RNG namespace `{namespace}` is recorded more than once")]
288    DuplicateNamespace {
289        /// Repeated application namespace.
290        namespace: String,
291    },
292    /// The reserved metadata entry is not an object indexed by namespace.
293    #[error("user metadata `{RNG_RECORDS_METADATA_KEY}` entry must be an object")]
294    InvalidMetadataShape,
295    /// A stored namespaced value does not decode as an RNG record.
296    #[error("invalid RNG record for namespace `{namespace}`")]
297    InvalidStoredRecord {
298        /// Namespace selected by the caller.
299        namespace: String,
300        /// JSON type or field failure.
301        #[source]
302        source: serde_json::Error,
303    },
304    /// The metadata index and embedded record namespace disagree.
305    #[error("RNG metadata index `{index}` contains record namespace `{record}`")]
306    NamespaceMismatch {
307        /// Namespace used as the metadata object key.
308        index: String,
309        /// Namespace embedded in the record.
310        record: String,
311    },
312}