Skip to main content

prospicio_prob/
provenance.rs

1//! Where a result came from: model, parameters, seed, versions and a hash
2//! of the input.
3
4/// Name of the rule mapping simulations to RNG streams used by
5/// [`crate::PredictiveDistribution::simulate`]: simulation `i` draws only
6/// from `StreamRng::new(seed, i)`. See `docs/design/rng.md`.
7pub const SIM_INDEX_SCHEME: &str = "chacha20/sim-index/v1";
8
9/// Audit record carried by every [`crate::PredictiveDistribution`], so a
10/// result can be traced to its model and replayed from its seed.
11///
12/// ```
13/// use prospicio_prob::Provenance;
14///
15/// let p = Provenance::new("odp_bootstrap").param("n_sims", 10_000);
16/// assert_eq!(p.model, "odp_bootstrap");
17/// assert_eq!(p.parameters, [("n_sims".to_string(), "10000".to_string())]);
18/// assert_eq!(p.versions[0].0, "prospicio-prob");
19/// ```
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Provenance {
22    /// Model that produced the result, e.g. `"odp_bootstrap"`.
23    pub model: String,
24    /// Model parameters in the order the model reports them.
25    pub parameters: Vec<(String, String)>,
26    /// Seed of the simulation, if the result is simulated.
27    pub seed: Option<u64>,
28    /// Rule mapping simulations to streams, e.g. [`SIM_INDEX_SCHEME`].
29    pub stream_scheme: Option<String>,
30    /// Crate versions involved, starting with `prospicio-prob`.
31    pub versions: Vec<(String, String)>,
32    /// Hash of the model's canonical input, from [`InputHasher`].
33    pub input_hash: Option<String>,
34}
35
36impl Provenance {
37    /// Provenance for `model`, recording this crate's version.
38    pub fn new(model: impl Into<String>) -> Self {
39        Self {
40            model: model.into(),
41            parameters: Vec::new(),
42            seed: None,
43            stream_scheme: None,
44            versions: vec![("prospicio-prob".into(), env!("CARGO_PKG_VERSION").into())],
45            input_hash: None,
46        }
47    }
48
49    /// Adds a model parameter.
50    pub fn param(mut self, name: impl Into<String>, value: impl ToString) -> Self {
51        self.parameters.push((name.into(), value.to_string()));
52        self
53    }
54
55    /// Records the version of another crate involved, e.g. the model's.
56    pub fn version(mut self, krate: impl Into<String>, version: impl Into<String>) -> Self {
57        self.versions.push((krate.into(), version.into()));
58        self
59    }
60
61    /// Records the seed and the stream scheme it was used with.
62    pub fn seed(mut self, seed: u64, stream_scheme: impl Into<String>) -> Self {
63        self.seed = Some(seed);
64        self.stream_scheme = Some(stream_scheme.into());
65        self
66    }
67
68    /// Records the hash of the model's canonical input, normally
69    /// [`InputHasher::finish`].
70    pub fn input_hash(mut self, hash: impl Into<String>) -> Self {
71        self.input_hash = Some(hash.into());
72        self
73    }
74}
75
76/// BLAKE3 key-derivation context for [`InputHasher`]. It separates input
77/// hashes from any other use of BLAKE3, and changes (`v2`, …) whenever the
78/// encoding below changes, so hashes from different encodings never match.
79pub const INPUT_HASH_CONTEXT: &str = "risk-rs 2026-09-30 input-hash v1";
80
81/// Hashes a model's input for [`Provenance::input_hash`], so an audit can
82/// confirm that two results came from the same data.
83///
84/// Each field is written as a one-byte type tag, a little-endian `u64`
85/// length, then the payload, so neither field boundaries nor types can be
86/// confused: `str("ab").str("c")` differs from `str("a").str("bc")`, and
87/// `u64(1)` from `i64(1)`. Floats are hashed by their bits, with `-0.0`
88/// written as `0.0` and every NaN as one canonical NaN, so numerically
89/// equal inputs hash equal.
90///
91/// ```
92/// use prospicio_prob::provenance::InputHasher;
93///
94/// let a = InputHasher::new().str("origin").f64s(&[100.0, 150.0]).finish();
95/// let b = InputHasher::new().str("origin").f64s(&[100.0, 150.0]).finish();
96/// assert_eq!(a, b);
97/// assert!(a.starts_with("blake3:"));
98/// assert_ne!(a, InputHasher::new().str("origin").f64s(&[100.0, 151.0]).finish());
99/// ```
100#[derive(Debug, Clone)]
101pub struct InputHasher {
102    inner: blake3::Hasher,
103}
104
105impl Default for InputHasher {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl InputHasher {
112    const BYTES: u8 = 1;
113    const STR: u8 = 2;
114    const U64: u8 = 3;
115    const I64: u8 = 4;
116    const F64S: u8 = 5;
117
118    /// An empty hasher keyed by [`INPUT_HASH_CONTEXT`].
119    pub fn new() -> Self {
120        Self {
121            inner: blake3::Hasher::new_derive_key(INPUT_HASH_CONTEXT),
122        }
123    }
124
125    fn header(&mut self, tag: u8, len: usize) {
126        self.inner.update(&[tag]);
127        self.inner.update(&(len as u64).to_le_bytes());
128    }
129
130    /// Adds raw bytes, e.g. an Arrow IPC buffer.
131    pub fn bytes(&mut self, bytes: &[u8]) -> &mut Self {
132        self.header(Self::BYTES, bytes.len());
133        self.inner.update(bytes);
134        self
135    }
136
137    /// Adds UTF-8 text, e.g. a column or dimension name.
138    pub fn str(&mut self, text: &str) -> &mut Self {
139        self.header(Self::STR, text.len());
140        self.inner.update(text.as_bytes());
141        self
142    }
143
144    /// Adds an unsigned integer.
145    pub fn u64(&mut self, value: u64) -> &mut Self {
146        self.header(Self::U64, 8);
147        self.inner.update(&value.to_le_bytes());
148        self
149    }
150
151    /// Adds a signed integer.
152    pub fn i64(&mut self, value: i64) -> &mut Self {
153        self.header(Self::I64, 8);
154        self.inner.update(&value.to_le_bytes());
155        self
156    }
157
158    /// Adds a sequence of floats; the length is the number of values.
159    pub fn f64s(&mut self, values: &[f64]) -> &mut Self {
160        self.header(Self::F64S, values.len());
161        for &x in values {
162            self.inner.update(&canonical_bits(x).to_le_bytes());
163        }
164        self
165    }
166
167    /// The hash of everything added so far, as `"blake3:"` followed by 64
168    /// hex digits. The hasher can keep taking fields afterwards.
169    pub fn finish(&self) -> String {
170        format!("blake3:{}", self.inner.finalize().to_hex())
171    }
172}
173
174/// Bits of `x` with `-0.0` mapped to `0.0` and every NaN to one NaN.
175fn canonical_bits(x: f64) -> u64 {
176    if x == 0.0 {
177        0
178    } else if x.is_nan() {
179        f64::NAN.to_bits()
180    } else {
181        x.to_bits()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn matches_an_independent_blake3() {
191        // Reproduced with the Python `blake3` package from bytes built by
192        // hand from the documented encoding:
193        // validation/scripts/input_hash_golden.py.
194        let h = InputHasher::new()
195            .str("origin")
196            .i64(1981)
197            .u64(10)
198            .f64s(&[5012.0, -0.0])
199            .bytes(b"arrow")
200            .finish();
201        assert_eq!(h, GOLDEN);
202        assert_eq!(InputHasher::new().finish(), GOLDEN_EMPTY);
203    }
204
205    const GOLDEN: &str = "blake3:ed7cae0b254756dbb56c21f9e069020938edd8bd1ddab977a75662dac9e53380";
206    const GOLDEN_EMPTY: &str =
207        "blake3:78ea3e208e78c7ab65dbea55824fdc5df3d386ef598ad5419bbf61d456013244";
208
209    fn hash(f: impl FnOnce(&mut InputHasher)) -> String {
210        let mut h = InputHasher::new();
211        f(&mut h);
212        h.finish()
213    }
214
215    #[test]
216    fn field_boundaries_and_types_are_distinct() {
217        assert_ne!(
218            hash(|h| {
219                h.str("ab").str("c");
220            }),
221            hash(|h| {
222                h.str("a").str("bc");
223            })
224        );
225        assert_ne!(
226            hash(|h| {
227                h.u64(1);
228            }),
229            hash(|h| {
230                h.i64(1);
231            })
232        );
233        assert_ne!(
234            hash(|h| {
235                h.str("a");
236            }),
237            hash(|h| {
238                h.bytes(b"a");
239            })
240        );
241        assert_ne!(
242            hash(|h| {
243                h.f64s(&[1.0, 2.0]);
244            }),
245            hash(|h| {
246                h.f64s(&[1.0]).f64s(&[2.0]);
247            })
248        );
249    }
250
251    #[test]
252    fn equal_numbers_hash_equal() {
253        assert_eq!(
254            hash(|h| {
255                h.f64s(&[-0.0]);
256            }),
257            hash(|h| {
258                h.f64s(&[0.0]);
259            })
260        );
261        let other_nan = f64::from_bits(f64::NAN.to_bits() | 1);
262        assert_eq!(
263            hash(|h| {
264                h.f64s(&[other_nan]);
265            }),
266            hash(|h| {
267                h.f64s(&[f64::NAN]);
268            })
269        );
270        assert_ne!(
271            hash(|h| {
272                h.f64s(&[1.0]);
273            }),
274            hash(|h| {
275                h.f64s(&[1.0 + f64::EPSILON]);
276            })
277        );
278    }
279
280    #[test]
281    fn is_not_plain_blake3() {
282        assert_ne!(
283            InputHasher::new().finish(),
284            format!("blake3:{}", blake3::hash(b"").to_hex())
285        );
286    }
287}