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