aprender_contrastive_data/rng.rs
1//! Domain-separated Philox key derivation and the bounded-draw primitive.
2//!
3//! The byte encoding is frozen by the contract: the key is a little-endian 64-bit
4//! truncation of a SHA-256 over a domain tag, the root seed, and a domain string drawn
5//! from a closed table; the counter carries the draw ordinal. Bounded draws use 64-bit
6//! multiply-shift. Modulo draws and `next_f32` are forbidden — the first is biased and
7//! unauditable at the edges, the second has only 23 mantissa bits.
8//!
9//! # Every function here is STATELESS
10//!
11//! No function in this module takes `&mut self`, and no mutable generator state crosses
12//! any boundary. Draw *i* is a pure function of `(key, stream_id, ordinal)`, so worker
13//! count, thread scheduling and iteration order cannot change it (D-20). That is the
14//! whole reason this crate uses a counter-based generator instead of the `rand_chacha`
15//! stream `.planning/research/STACK.md` recommends: with a stateful stream, draw *i*
16//! depends on every draw before it, and worker-count independence becomes a discipline
17//! the implementation must maintain rather than a fact about its type.
18//!
19//! # Philox is a STATISTICAL generator, never a CSPRNG
20//!
21//! Philox 4x32-10 is used here solely for sampling determinism. It is **not**
22//! cryptographic randomness: the key is derived from a caller-visible seed, the stream is
23//! seekable by construction, and nothing about it resists an adversary who knows the
24//! seed. Never reuse anything in this module for tokens, nonces, salts or key material.
25//!
26//! # The frozen byte encoding
27//!
28//! Every byte-level decision below is contracted (`rng_key_derivation`, `bounded_draw`)
29//! and pinned by [`rng_tests::rng_byte_encoding_golden_is_frozen`], whose constants were
30//! derived from the contract text by an independent implementation rather than captured
31//! from a first run of this code:
32//!
33//! | Decision | Value |
34//! |---|---|
35//! | domain tag | `b"apr-contrastive-v1\0"` — 18 ASCII bytes plus one NUL terminator |
36//! | root seed | `u64::to_le_bytes`, exactly 8 bytes |
37//! | key truncation | digest bytes `0..8` as two LITTLE-ENDIAN `u32` lanes; `8..32` discarded |
38//! | counter | `[ordinal as u32, (ordinal >> 32) as u32, stream_id, 0]` |
39//! | 64-bit assembly | `((lanes[1] as u64) << 32) \| (lanes[0] as u64)` — lane 0 is the LOW half |
40//! | bounded draw | `((x as u128 * n as u128) >> 64) as u64` — multiply-shift, never modulo |
41
42use core::num::NonZeroU64;
43
44use sha2::{Digest, Sha256};
45use trueno_rand::Philox4x32;
46
47/// The frozen domain-separation tag.
48///
49/// The trailing NUL is load-bearing: without a terminator, the tag and the seed bytes are
50/// ambiguous under concatenation, so a different tag with a different seed could derive
51/// the same key.
52const DOMAIN_TAG: &[u8] = b"apr-contrastive-v1\0";
53
54/// A Philox key derived from a `(root_seed, domain)` pair.
55///
56/// Opaque on purpose: the only way to obtain one is [`derive_key`], so no call site can
57/// invent a key that skips domain separation.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct DomainKey([u32; 2]);
60
61impl DomainKey {
62 /// The two Philox key lanes, in derivation order.
63 ///
64 /// Exposed so a golden test can pin the byte encoding by value rather than by
65 /// behaviour: an endianness or truncation change must be visible as a number in a
66 /// diff, not only as a downstream selection that quietly moved.
67 pub fn lanes(self) -> [u32; 2] {
68 self.0
69 }
70}
71
72/// Derive a domain-separated Philox key.
73///
74/// `key = trunc64_le(SHA-256(DOMAIN_TAG ‖ root_seed.to_le_bytes() ‖ domain.as_bytes()))`,
75/// where `trunc64_le` reads digest bytes `0..8` as
76/// `[u32::from_le_bytes(d[0..4]), u32::from_le_bytes(d[4..8])]`. Digest bytes `8..32` are
77/// discarded.
78///
79/// The little-endian choices are stated rather than inherited from the host: a big-endian
80/// machine that read these bytes natively would derive a different key for the same seed,
81/// and the divergence would surface only as a different set of selected examples.
82#[provable_contracts_macros::contract(
83 "contrastive-pair-protocol-v1",
84 equation = "rng_key_derivation"
85)]
86pub fn derive_key(root_seed: u64, domain: &str) -> DomainKey {
87 let mut hasher = Sha256::new();
88 hasher.update(DOMAIN_TAG);
89 hasher.update(root_seed.to_le_bytes());
90 hasher.update(domain.as_bytes());
91 let digest: [u8; 32] = hasher.finalize().into();
92
93 // Indexing is safe by construction: a SHA-256 digest is exactly 32 bytes, so both
94 // 4-byte windows exist. `try_into` on a fixed-size slice cannot fail here, and the
95 // fallback keeps the function total without an `unwrap`.
96 let lane0 = u32::from_le_bytes([digest[0], digest[1], digest[2], digest[3]]);
97 let lane1 = u32::from_le_bytes([digest[4], digest[5], digest[6], digest[7]]);
98 DomainKey([lane0, lane1])
99}
100
101/// One Philox 4x32-10 output block at `(key, stream_id, ordinal)`.
102///
103/// `counter = [ordinal as u32, (ordinal >> 32) as u32, stream_id, 0]`. The result depends
104/// on nothing else — not on how many draws preceded it, not on which thread asks, not on
105/// the order the ordinals are requested in.
106pub fn draw(key: &DomainKey, stream_id: u32, ordinal: u64) -> [u32; 4] {
107 let counter = [ordinal as u32, (ordinal >> 32) as u32, stream_id, 0];
108 Philox4x32::generate_at(key.0, counter)
109}
110
111/// Assemble a 64-bit value from an output block: lane 0 is the LOW half.
112///
113/// Frozen for the same reason the seed encoding is — the opposite convention is equally
114/// natural and would silently produce a different, equally plausible-looking stream.
115fn assemble64(lanes: [u32; 4]) -> u64 {
116 (u64::from(lanes[1]) << 32) | u64::from(lanes[0])
117}
118
119/// A uniform-ish draw in `[0, n)` by 64-bit multiply-shift.
120///
121/// `bounded(..) = ((x as u128 * n.get() as u128) >> 64) as u64`, where `x` is
122/// [`assemble64`] of the block at `(key, stream_id, ordinal)`.
123///
124/// # This IS the contracted derivation
125///
126/// Its non-uniformity is below 2⁻⁴⁴ for every range this protocol reaches (class buckets
127/// ≤ 587 rows, pair spaces ≤ ~10⁵), and it is branch-free and index-pure. Contract
128/// assumption A3 records that as the derivation itself, not as an approximation of some
129/// exact-uniform alternative — so a future reader does not "correct" it into rejection
130/// sampling and change every sampled identity in the process.
131///
132/// Modulo is FORBIDDEN: its bias at the top of the range is real and, worse, unauditable —
133/// two implementations can both look correct and disagree. Float scaling is FORBIDDEN: a
134/// 23-bit mantissa cannot address a bucket beyond 2²⁴ without collisions.
135///
136/// # A zero bound is unrepresentable
137///
138/// `n` is a [`NonZeroU64`], so `bounded(.., 0)` is a type error rather than a silent
139/// constant or a fault:
140///
141/// ```compile_fail
142/// use aprender_contrastive_data::rng::{bounded, derive_key};
143///
144/// let key = derive_key(13, "select/0");
145/// let _ = bounded(&key, 0, 0, 0u64);
146/// ```
147///
148/// The same call with a real bound compiles, which is what stops the block above from
149/// being green for an unrelated reason:
150///
151/// ```
152/// use core::num::NonZeroU64;
153/// use aprender_contrastive_data::rng::{bounded, derive_key};
154///
155/// let key = derive_key(13, "select/0");
156/// let one = NonZeroU64::new(1).expect("1 is not zero");
157/// assert_eq!(bounded(&key, 0, 0, one), 0);
158/// ```
159#[provable_contracts_macros::contract("contrastive-pair-protocol-v1", equation = "bounded_draw")]
160pub fn bounded(key: &DomainKey, stream_id: u32, ordinal: u64, n: NonZeroU64) -> u64 {
161 let x = assemble64(draw(key, stream_id, ordinal));
162 ((u128::from(x) * u128::from(n.get())) >> 64) as u64
163}
164
165/// The six frozen domain strings of protocol v1.
166///
167/// Domain separation is the ONLY mechanism keeping the selection stream and the two pair
168/// streams independent — they share a root seed by design — so a collision between two of
169/// these strings would correlate the streams without any test failing. The table is
170/// therefore closed: adding a seventh string, or changing how an existing one renders, is
171/// a versioned contract change, because either alters every sampled identity.
172///
173/// The `#[contract]` annotation on [`select`] covers this whole table. It sits there
174/// rather than on the five constants because an attribute macro cannot annotate a `const`,
175/// and because `select` is the only entry that FORMATS — hence the only one that could
176/// ever drift across platforms.
177pub mod domains {
178 /// The selection domain for one class label: `"select/{label}"`.
179 ///
180 /// `label` renders as a base-10 `usize` with NO zero padding, NO thousands separator
181 /// and NO locale awareness: class 7 is `"select/7"`, never `"select/07"`.
182 #[provable_contracts_macros::contract(
183 "contrastive-pair-protocol-v1",
184 equation = "rng_domain_strings"
185 )]
186 pub fn select(label: usize) -> String {
187 format!("select/{label}")
188 }
189
190 /// Positive-pair class choice.
191 pub const PAIRS_POS_CLASS: &str = "pairs/pos/class";
192 /// Positive-pair member unranking.
193 pub const PAIRS_POS_RANK: &str = "pairs/pos/rank";
194 /// Negative-pair class-pair choice.
195 pub const PAIRS_NEG_CLASS: &str = "pairs/neg/class";
196 /// Negative-pair first member.
197 pub const PAIRS_NEG_FIRST: &str = "pairs/neg/first";
198 /// Negative-pair second member.
199 pub const PAIRS_NEG_SECOND: &str = "pairs/neg/second";
200}
201
202#[cfg(test)]
203mod rng_tests {
204 use super::{assemble64, bounded, derive_key, domains, draw, DomainKey};
205 use core::num::NonZeroU64;
206 use proptest::prelude::{prop_assert, proptest};
207
208 /// Golden constants for the frozen byte encoding.
209 ///
210 /// **Derivation, so a future re-baseline is reviewable rather than invisible.** These
211 /// were produced by an independent Python implementation written from the contract
212 /// text (`rng_key_derivation`, `bounded_draw`) and the Philox 4x32-10 definition in
213 /// Salmon et al. (2011) — not by running this module and blessing its output. The key
214 /// lanes were additionally cross-checked against `shasum -a 256` over the literal
215 /// byte string `apr-contrastive-v1\0` ‖ `0d 00 00 00 00 00 00 00` ‖ `select/0`, which
216 /// yields digest `b9102802 dc22ce71 …`; reading its first two 4-byte windows
217 /// little-endian gives exactly `KEY_13_SELECT_0`.
218 ///
219 /// If this test goes red, an endianness, truncation, counter-layout or lane-assembly
220 /// decision changed. That is a versioned contract change, never a re-blessing.
221 const KEY_13_SELECT_0: [u32; 2] = [0x0228_10b9, 0x71ce_22dc];
222 const KEY_13_SELECT_1: [u32; 2] = [465_649_502, 1_683_967_742];
223 const KEY_14_SELECT_0: [u32; 2] = [1_239_703_332, 3_359_937_302];
224 const BLOCK_AT_ORDINAL_7: [u32; 4] =
225 [1_281_016_082, 3_815_106_876, 1_099_144_567, 2_908_329_261];
226 const ASSEMBLED_AT_ORDINAL_7: u64 = 16_385_759_264_445_743_378;
227 const BOUNDED_7_587: u64 = 521;
228 const BOUNDED_7_24576: u64 = 21_830;
229 const BOUNDED_HIGH_ORDINAL_587: u64 = 419;
230
231 fn nz(n: u64) -> NonZeroU64 {
232 NonZeroU64::new(n).expect("test bounds are non-zero by construction")
233 }
234
235 #[test]
236 fn rng_byte_encoding_golden_is_frozen() {
237 let key = derive_key(13, "select/0");
238 assert_eq!(key.lanes(), KEY_13_SELECT_0);
239 assert_eq!(derive_key(13, "select/1").lanes(), KEY_13_SELECT_1);
240 assert_eq!(derive_key(14, "select/0").lanes(), KEY_14_SELECT_0);
241
242 // Counter layout and Philox invocation.
243 assert_eq!(draw(&key, 0, 7), BLOCK_AT_ORDINAL_7);
244
245 // Lane assembly: lane 0 is the LOW half. Pinned as a VALUE rather than restated
246 // as `(lanes[1] << 32) | lanes[0]`, which would only re-derive the implementation
247 // and would stay green if both sides were swapped together.
248 assert_eq!(assemble64(BLOCK_AT_ORDINAL_7), ASSEMBLED_AT_ORDINAL_7);
249
250 // Multiply-shift, including one case that exercises the HIGH ordinal word and a
251 // non-zero stream id — the two counter lanes a naive implementation drops.
252 assert_eq!(bounded(&key, 0, 7, nz(587)), BOUNDED_7_587);
253 assert_eq!(bounded(&key, 0, 7, nz(24_576)), BOUNDED_7_24576);
254 assert_eq!(
255 bounded(&key, 3, 12_345_678_901, nz(587)),
256 BOUNDED_HIGH_ORDINAL_587
257 );
258 }
259
260 #[test]
261 fn rng_derive_key_separates_domains_and_seeds() {
262 let base = derive_key(42, "select/0");
263 assert_ne!(base, derive_key(42, "select/1"), "domain separation");
264 assert_ne!(base, derive_key(43, "select/0"), "seed separation");
265 assert_ne!(
266 derive_key(42, domains::PAIRS_POS_CLASS),
267 derive_key(42, domains::PAIRS_NEG_CLASS),
268 "the pair domains must not collide"
269 );
270 assert_eq!(base, derive_key(42, "select/0"), "and it is reproducible");
271 }
272
273 /// Purity: draw *i* is a function of its index, so requesting ordinals out of order
274 /// yields the same values as requesting them in order.
275 ///
276 /// This is the property that makes worker-count independence structural. A stateful
277 /// stream would fail it, and no amount of "we always draw in order" discipline would
278 /// make it true.
279 #[test]
280 fn rng_draw_is_pure_and_order_independent() {
281 let key = derive_key(13, domains::PAIRS_POS_RANK);
282
283 let in_order: Vec<[u32; 4]> = [1_u64, 3, 5].iter().map(|i| draw(&key, 0, *i)).collect();
284 let shuffled: Vec<[u32; 4]> = [5_u64, 1, 3].iter().map(|i| draw(&key, 0, *i)).collect();
285 assert_eq!(in_order, vec![shuffled[1], shuffled[2], shuffled[0]]);
286
287 // Twice is twice, and different ordinals are different draws.
288 assert_eq!(draw(&key, 0, 5), draw(&key, 0, 5));
289 assert_ne!(draw(&key, 0, 5), draw(&key, 0, 6));
290 // Streams separate too, at the same ordinal.
291 assert_ne!(draw(&key, 0, 5), draw(&key, 1, 5));
292 }
293
294 #[test]
295 fn rng_bounded_with_bound_one_is_always_zero() {
296 let key = derive_key(29, "select/2");
297 for ordinal in 0..256 {
298 assert_eq!(bounded(&key, 0, ordinal, nz(1)), 0);
299 }
300 }
301
302 #[test]
303 fn rng_domains_select_renders_base_ten_unpadded() {
304 assert_eq!(domains::select(0), "select/0");
305 assert_eq!(domains::select(7), "select/7");
306 assert_eq!(domains::select(12), "select/12");
307 assert_eq!(domains::select(1_024), "select/1024");
308 // The other five are literals with no interpolation to drift.
309 assert_eq!(domains::PAIRS_POS_CLASS, "pairs/pos/class");
310 assert_eq!(domains::PAIRS_POS_RANK, "pairs/pos/rank");
311 assert_eq!(domains::PAIRS_NEG_CLASS, "pairs/neg/class");
312 assert_eq!(domains::PAIRS_NEG_FIRST, "pairs/neg/first");
313 assert_eq!(domains::PAIRS_NEG_SECOND, "pairs/neg/second");
314 }
315
316 /// The key is opaque, so the only way to get one is through domain separation.
317 #[test]
318 fn rng_domain_key_is_copy_and_comparable() {
319 let key: DomainKey = derive_key(17, "select/0");
320 let copied = key;
321 assert_eq!(key, copied);
322 }
323
324 proptest! {
325 /// `bounded(..) < n` for every bound this protocol reaches, over 10_000 ordinals.
326 #[test]
327 fn rng_bounded_is_always_below_its_bound(ordinal in 0_u64..10_000) {
328 let key = derive_key(31, "select/1");
329 for n in [1_u64, 2, 3, 587, 24_576] {
330 let bound = NonZeroU64::new(n).expect("literal bounds are non-zero");
331 prop_assert!(bounded(&key, 0, ordinal, bound) < n);
332 }
333 }
334 }
335}