Skip to main content

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}