prospicio_prob/
provenance.rs1pub const SIM_INDEX_SCHEME: &str = "chacha20/sim-index/v1";
8
9#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Provenance {
22 pub model: String,
24 pub parameters: Vec<(String, String)>,
26 pub seed: Option<u64>,
28 pub stream_scheme: Option<String>,
30 pub versions: Vec<(String, String)>,
32 pub input_hash: Option<String>,
34}
35
36impl Provenance {
37 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 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 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 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 pub fn input_hash(mut self, hash: impl Into<String>) -> Self {
71 self.input_hash = Some(hash.into());
72 self
73 }
74}
75
76pub const INPUT_HASH_CONTEXT: &str = "risk-rs 2026-09-30 input-hash v1";
80
81#[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 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 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 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 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 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 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 pub fn finish(&self) -> String {
170 format!("blake3:{}", self.inner.finalize().to_hex())
171 }
172}
173
174fn 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 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}