Skip to main content

rlx_ir/
rng.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Counter-based and ONNX Runtime–compatible RNG for in-graph random ops.
6//!
7//! # Behavioral contract
8//!
9//! `Op::RngNormal` / `Op::RngUniform` take an optional shape-template input
10//! (ONNX `Random*Like`) or no inputs when the output shape is fixed at import
11//! time (ONNX `Random*` with a `shape` attribute). The output tensor shape is
12//! always the node's assigned shape; the template input is not copied into the
13//! output.
14//!
15//! | Backend | Semantics |
16//! |---------|-----------|
17//! | [`RngBackend::Philox`] | Deterministic Philox4×32-10 stream keyed by [`RngOptions::seed`] + per-node `key`. Default for RLX-native runs. |
18//! | [`RngBackend::Ort`] | Matches ONNX Runtime CPU `Random*` (`minstd_rand0` + polar normal / uniform). Use for import parity tests. Per-op ONNX `seed` (f32) overrides the mixed engine seed when set. |
19//! | [`RngBackend::Bnns`] | Portable Apple BNNS AES-CTR-128 stream (`BNNSCreateRandomGeneratorWithSeed`) + BNNS uniform byte mapping; Box–Muller normals; see [`BnnsAesCtr128`]. |
20//! | [`RngBackend::Zero`] | Writes zeros — useful when comparing against a stochastic reference without re-seeding ORT. |
21//!
22//! Policy is set at compile time via `CompileOptions::rng` and can be overridden
23//! per session through `rlx_runtime::CompiledGraph::set_rng` without
24//! recompiling. Each execute re-seeds from the current policy (ORT session state
25//! is not advanced across runs today).
26
27use aes::Aes128;
28use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
29
30/// Which RNG implementation to use for `Op::RngNormal` / `Op::RngUniform`.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
33pub enum RngBackend {
34    /// Philox4×32-10 sequential stream (RLX native default).
35    #[default]
36    Philox,
37    /// ONNX Runtime CPU `Random*Like` (`minstd_rand0` + `std::normal_distribution`).
38    Ort,
39    /// Apple BNNS AES-CTR-128 (`BNNSRandomGeneratorMethodAES_CTR`) + uniform mapping.
40    Bnns,
41    /// Fill with zero (deterministic parity vs stochastic reference runs).
42    Zero,
43}
44
45/// Compile-time / execute-time RNG policy for graphs containing random ops.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
48pub struct RngOptions {
49    /// Global seed mixed into per-node keys (maps to ORT session seed).
50    pub seed: u64,
51    pub backend: RngBackend,
52}
53
54impl Default for RngOptions {
55    fn default() -> Self {
56        Self {
57            seed: 42,
58            backend: RngBackend::Philox,
59        }
60    }
61}
62
63impl RngOptions {
64    pub const fn new(seed: u64, backend: RngBackend) -> Self {
65        Self { seed, backend }
66    }
67
68    pub fn philox(seed: u64) -> Self {
69        Self {
70            seed,
71            backend: RngBackend::Philox,
72        }
73    }
74
75    pub fn ort(seed: u64) -> Self {
76        Self {
77            seed,
78            backend: RngBackend::Ort,
79        }
80    }
81
82    pub fn bnns(seed: u64) -> Self {
83        Self {
84            seed,
85            backend: RngBackend::Bnns,
86        }
87    }
88
89    pub fn zero() -> Self {
90        Self {
91            seed: 0,
92            backend: RngBackend::Zero,
93        }
94    }
95}
96
97/// Mix a global compile seed with a per-node key (ONNX node name hash).
98pub fn combine_seed(global: u64, key: u64) -> u64 {
99    global.wrapping_add(key.wrapping_mul(0x9E37_79B9_7F4A_7C15))
100}
101
102/// ORT CPU engine seed: explicit ONNX `seed` attr cast to u32, else global+key.
103pub fn ort_engine_seed(global: u64, key: u64, op_seed: Option<f32>) -> u32 {
104    if let Some(s) = op_seed {
105        s as u32
106    } else {
107        global.wrapping_add(key) as u32
108    }
109}
110
111/// Fill `out` with `mean + scale * N(0,1)` samples.
112pub fn fill_normal_like(
113    out: &mut [f32],
114    mean: f32,
115    scale: f32,
116    opts: RngOptions,
117    key: u64,
118    op_seed: Option<f32>,
119) {
120    match opts.backend {
121        RngBackend::Zero => out.fill(0.0),
122        RngBackend::Philox => {
123            let mut rng = Philox4x32::new(combine_seed(opts.seed, key));
124            for v in out.iter_mut() {
125                *v = mean + scale * rng.normal();
126            }
127        }
128        RngBackend::Ort => {
129            let mut eng = MinstdRand0::new(ort_engine_seed(opts.seed, key, op_seed));
130            let mut dist = StdNormalDist::new(mean, scale);
131            for v in out.iter_mut() {
132                *v = dist.sample(&mut eng);
133            }
134        }
135        RngBackend::Bnns => {
136            let _ = op_seed;
137            let mut rng = BnnsAesCtr128::new(combine_seed(opts.seed, key));
138            rng.fill_normal(out, mean, scale);
139        }
140    }
141}
142
143/// Fill `out` with uniform samples in `[low, high)`.
144pub fn fill_uniform_like(
145    out: &mut [f32],
146    low: f32,
147    high: f32,
148    opts: RngOptions,
149    key: u64,
150    op_seed: Option<f32>,
151) {
152    match opts.backend {
153        RngBackend::Zero => out.fill(0.0),
154        RngBackend::Philox => {
155            let mut rng = Philox4x32::new(combine_seed(opts.seed, key));
156            for v in out.iter_mut() {
157                *v = rng.uniform(low, high);
158            }
159        }
160        RngBackend::Ort => {
161            let mut eng = MinstdRand0::new(ort_engine_seed(opts.seed, key, op_seed));
162            for v in out.iter_mut() {
163                *v = low + (high - low) * eng.unit_f32();
164            }
165        }
166        RngBackend::Bnns => {
167            let _ = op_seed;
168            let mut rng = BnnsAesCtr128::new(combine_seed(opts.seed, key));
169            rng.fill_uniform(out, low, high);
170        }
171    }
172}
173
174/// Philox4×32 counter-based RNG. Produces 4 u32s per round of the
175/// core hash — we expose an iterator that yields one f32 per call.
176#[derive(Debug, Clone, Copy)]
177pub struct Philox4x32 {
178    seed: [u32; 2],
179    counter: [u32; 4],
180    /// Cached output buffer + cursor into it.
181    buffer: [u32; 4],
182    cursor: u8,
183}
184
185impl Philox4x32 {
186    pub const fn new(seed: u64) -> Self {
187        let lo = (seed & 0xFFFF_FFFF) as u32;
188        let hi = (seed >> 32) as u32;
189        Self {
190            seed: [lo, hi],
191            counter: [0, 0, 0, 0],
192            buffer: [0; 4],
193            cursor: 4, // empty — next next_u32 fills the buffer
194        }
195    }
196
197    fn round(state: &mut [u32; 4], key: [u32; 2]) {
198        const M0: u64 = 0xD256_1A75;
199        const M1: u64 = 0xCD9E_8D57;
200        let p0 = (state[0] as u64) * M0;
201        let p1 = (state[2] as u64) * M1;
202        let hi0 = (p0 >> 32) as u32;
203        let lo0 = p0 as u32;
204        let hi1 = (p1 >> 32) as u32;
205        let lo1 = p1 as u32;
206        state[0] = hi1 ^ state[1] ^ key[0];
207        state[1] = lo1;
208        state[2] = hi0 ^ state[3] ^ key[1];
209        state[3] = lo0;
210    }
211
212    fn fill_buffer(&mut self) {
213        let mut state = self.counter;
214        let mut key = self.seed;
215        for _ in 0..10 {
216            Self::round(&mut state, key);
217            // Bump the key on every round (Philox key schedule).
218            key[0] = key[0].wrapping_add(0x9E37_79B9);
219            key[1] = key[1].wrapping_add(0xBB67_AE85);
220        }
221        self.buffer = state;
222        self.cursor = 0;
223
224        // Increment the 128-bit counter.
225        let (c0, of0) = self.counter[0].overflowing_add(1);
226        self.counter[0] = c0;
227        if of0 {
228            let (c1, of1) = self.counter[1].overflowing_add(1);
229            self.counter[1] = c1;
230            if of1 {
231                let (c2, of2) = self.counter[2].overflowing_add(1);
232                self.counter[2] = c2;
233                if of2 {
234                    self.counter[3] = self.counter[3].wrapping_add(1);
235                }
236            }
237        }
238    }
239
240    pub fn next_u32(&mut self) -> u32 {
241        if self.cursor >= 4 {
242            self.fill_buffer();
243        }
244        let v = self.buffer[self.cursor as usize];
245        self.cursor += 1;
246        v
247    }
248
249    /// Uniform `[0, 1)` f32 — the top 24 bits of a u32 give exactly
250    /// f32 mantissa precision.
251    pub fn next_f32(&mut self) -> f32 {
252        let bits = self.next_u32() >> 8;
253        bits as f32 / (1u32 << 24) as f32
254    }
255
256    /// Uniform `[lo, hi)` f32.
257    pub fn uniform(&mut self, lo: f32, hi: f32) -> f32 {
258        lo + self.next_f32() * (hi - lo)
259    }
260
261    /// Standard-normal `f32` via Box-Muller. Returns one sample;
262    /// the second is discarded (we don't cache to keep the type
263    /// `Copy`-able).
264    pub fn normal(&mut self) -> f32 {
265        let u1 = self.next_f32().max(f32::MIN_POSITIVE);
266        let u2 = self.next_f32();
267        let r = (-2.0 * u1.ln()).sqrt();
268        let theta = 2.0 * std::f32::consts::PI * u2;
269        r * theta.cos()
270    }
271
272    /// Fill `out` with uniform `[0, 1)` samples. Convenience for
273    /// weight init.
274    pub fn fill_uniform(&mut self, out: &mut [f32]) {
275        for v in out {
276            *v = self.next_f32();
277        }
278    }
279
280    /// Fill `out` with N(0, 1) samples.
281    pub fn fill_normal(&mut self, out: &mut [f32]) {
282        for v in out {
283            *v = self.normal();
284        }
285    }
286
287    /// One `Gumbel(0,1)` sample via inverse CDF: `-ln(-ln(u))`.
288    ///
289    /// Uniform `u` is clamped to `[ε, 1−ε]` with `ε = 1e-6` (WaveRNN /
290    /// Espresso `gumbel_max` convention) so `ln` stays finite.
291    pub fn gumbel01(&mut self) -> f32 {
292        gumbel01_from_uniform(self.next_f32())
293    }
294
295    /// Espresso-style `gumbel_max`: `argmax_i(logit_i + scale * Gumbel(0,1))`.
296    ///
297    /// Draws one Philox uniform per logit. Equivalent in distribution to
298    /// multinomial from `softmax(logits / scale)`, but the RNG trajectory
299    /// differs — WaveRNN must use this path, not [`crate::Op::Sample`].
300    pub fn gumbel_max_argmax(&mut self, logits: &[f32], scale: f32) -> usize {
301        gumbel_max_argmax_with(logits, scale, || self.gumbel01())
302    }
303}
304
305/// Portable reproduction of `BNNSRandomGeneratorMethodAES_CTR`.
306///
307/// Apple's seeded BNNS generator expands a `u64` seed by repeating its
308/// little-endian bytes to form the AES-128 key, encrypts a big-endian 128-bit
309/// counter starting at zero, and consumes each encrypted block as four
310/// little-endian `u32` words. Keeping this implementation in RLX makes the
311/// stream reproducible away from Apple platforms too.
312pub struct BnnsAesCtr128 {
313    cipher: Aes128,
314    counter: u128,
315    block: [u8; 16],
316    cursor: u8,
317    /// Unused Box–Muller sin sample within the current [`Self::fill_normal`].
318    normal_spare: Option<f32>,
319}
320
321impl BnnsAesCtr128 {
322    /// Construct the stream produced by
323    /// `BNNSCreateRandomGeneratorWithSeed(BNNSRandomGeneratorMethodAES_CTR, seed, ...)`.
324    pub fn new(seed: u64) -> Self {
325        let seed = seed.to_le_bytes();
326        let mut key = [0u8; 16];
327        key[..8].copy_from_slice(&seed);
328        key[8..].copy_from_slice(&seed);
329        Self {
330            cipher: Aes128::new(&Array::from(key)),
331            counter: 0,
332            block: [0; 16],
333            cursor: 16,
334            normal_spare: None,
335        }
336    }
337
338    /// Match `BNNSCreateRandomGenerator` (AES-CTR seeded from OS entropy).
339    ///
340    /// Apple's unseeded constructor draws an internal `u64` seed and then
341    /// follows the same path as [`Self::new`]. The seed value itself is not
342    /// observable without `BNNSRandomGeneratorGetState`; use
343    /// [`bnns_seed_from_generator_state`] when replaying a captured Apple run.
344    pub fn from_entropy() -> Self {
345        Self::new(bnns_entropy_seed())
346    }
347
348    /// Replay a generator whose 40-byte `BNNSRandomGeneratorGetState` blob was
349    /// captured **before** any draws (counter/cursor still zero).
350    ///
351    /// Returns `None` if `state` is too short or the generator has already
352    /// advanced (non-zero counter/cursor) — advanced states need a fuller
353    /// restore than seed-only construction.
354    pub fn try_from_fresh_generator_state(state: &[u8]) -> Option<Self> {
355        let seed = bnns_seed_from_generator_state(state)?;
356        if !bnns_generator_state_is_fresh(state) {
357            return None;
358        }
359        Some(Self::new(seed))
360    }
361
362    fn fill_block(&mut self) {
363        let mut block = Array::from(self.counter.to_be_bytes());
364        self.cipher.encrypt_block(&mut block);
365        self.block.copy_from_slice(&block);
366        self.counter = self.counter.wrapping_add(1);
367        self.cursor = 0;
368    }
369
370    /// Next AES stream word, using BNNS's little-endian grouping.
371    pub fn next_u32(&mut self) -> u32 {
372        if self.cursor >= 16 {
373            self.fill_block();
374        }
375        let start = self.cursor as usize;
376        self.cursor += 4;
377        u32::from_le_bytes(
378            self.block[start..start + 4]
379                .try_into()
380                .expect("four-byte AES word"),
381        )
382    }
383
384    /// Next float using the conversion performed inside
385    /// `BNNSRandomFillUniformFloat`.
386    ///
387    /// BNNS rounds the complete 32-bit integer to `f32` and scales by 2^-32;
388    /// it does not truncate to 23 or 24 random bits first.
389    pub fn next_f32(&mut self) -> f32 {
390        bnns_uniform_f32_from_u32(self.next_u32())
391    }
392
393    /// Uniform `[lo, hi]` sample following BNNS float arithmetic.
394    pub fn uniform(&mut self, lo: f32, hi: f32) -> f32 {
395        self.next_f32().mul_add(hi - lo, lo)
396    }
397
398    /// Fill with the AES-CTR stream used by `BNNSRandomFillUniformFloat`.
399    pub fn fill_uniform(&mut self, out: &mut [f32], lo: f32, hi: f32) {
400        for value in out {
401            *value = self.uniform(lo, hi);
402        }
403    }
404
405    /// One Espresso-compatible Gumbel value from BNNS's private byte mapping.
406    pub fn gumbel01(&mut self) -> f32 {
407        gumbel01_from_uniform(self.next_f32())
408    }
409
410    /// BNNS activation Gumbel: `−log(−log(α·U+β)+β)`.
411    pub fn gumbel(&mut self, alpha: f32, beta: f32) -> f32 {
412        bnns_gumbel_from_uniform(self.next_f32(), alpha, beta)
413    }
414
415    /// Fill `out` with Gumbel(0,1) samples from the BNNS AES-CTR stream.
416    pub fn fill_gumbel01(&mut self, out: &mut [f32]) {
417        for value in out {
418            *value = self.gumbel01();
419        }
420    }
421
422    /// Fill with BNNS-style Gumbel(`α`, `β`) noise.
423    pub fn fill_gumbel(&mut self, out: &mut [f32], alpha: f32, beta: f32) {
424        for value in out {
425            *value = self.gumbel(alpha, beta);
426        }
427    }
428
429    /// One `N(0,1)` sample via Box–Muller on BNNS uniforms.
430    ///
431    /// Within a [`Self::fill_normal`] call the sin half of each pair is kept as
432    /// a spare (Apple's batch behavior). Across separate fills the spare is
433    /// dropped, matching `BNNSRandomFillNormalFloat`.
434    pub fn normal(&mut self) -> f32 {
435        if let Some(spare) = self.normal_spare.take() {
436            return spare;
437        }
438        let u1 = self.next_f32().clamp(f32::MIN_POSITIVE, 1.0 - f32::EPSILON);
439        let u2 = self.next_f32().clamp(0.0, 1.0 - f32::EPSILON);
440        let r = (-2.0 * u1.ln()).sqrt();
441        let theta = 2.0 * std::f32::consts::PI * u2;
442        self.normal_spare = Some(r * theta.sin());
443        r * theta.cos()
444    }
445
446    /// Fill `out` with `mean + stddev * N(0,1)` like `BNNSRandomFillNormalFloat`.
447    pub fn fill_normal(&mut self, out: &mut [f32], mean: f32, stddev: f32) {
448        self.normal_spare = None;
449        for value in out {
450            *value = self.normal().mul_add(stddev, mean);
451        }
452        self.normal_spare = None;
453    }
454
455    /// Uniform integer in `[lo, hi)` via Lemire multiply-high on AES words.
456    ///
457    /// Apple's `BNNSRandomFillUniformInt` uses this stream for scalar fills
458    /// (`n < 16`) but switches to a different vectorized trajectory for larger
459    /// `n`; this portable path always stays on the AES word stream.
460    pub fn uniform_int(&mut self, lo: i64, hi: i64) -> i64 {
461        bnns_uniform_int_from_u32(self.next_u32(), lo, hi)
462    }
463
464    /// Fill `out` with integers in `[lo, hi)`.
465    pub fn fill_uniform_int(&mut self, out: &mut [i64], lo: i64, hi: i64) {
466        for value in out {
467            *value = self.uniform_int(lo, hi);
468        }
469    }
470
471    /// Categorical indices from class probabilities (or log-probabilities).
472    ///
473    /// Matches `BNNSRandomFillCategoricalFloat`: one BNNS uniform per draw,
474    /// inverse-CDF over the (softmax-)normalized class weights.
475    pub fn fill_categorical(
476        &mut self,
477        out: &mut [f32],
478        probabilities: &[f32],
479        log_probabilities: bool,
480    ) {
481        let probs = bnns_normalize_probs(probabilities, log_probabilities);
482        for value in out {
483            *value = bnns_categorical_from_uniform(self.next_f32(), &probs) as f32;
484        }
485    }
486
487    /// Espresso `gumbel_max` using BNNS uniforms + ε-clamped ICDF.
488    pub fn gumbel_max_argmax(&mut self, logits: &[f32], scale: f32) -> usize {
489        gumbel_max_argmax_with(logits, scale, || self.gumbel01())
490    }
491
492    /// BNNS `GumbelMax` activation: `argmax_j(logit_j + Gumbel(α,β))`.
493    pub fn bnns_gumbel_max_argmax(&mut self, logits: &[f32], alpha: f32, beta: f32) -> usize {
494        gumbel_max_argmax_with(logits, 1.0, || self.gumbel(alpha, beta))
495    }
496}
497
498/// Serialized access to Apple's process-global BNNS `GumbelMax` stream.
499///
500/// The native activation does not accept a caller-owned random generator.
501/// Holding this value prevents another RLX native sampler from interleaving
502/// draws after the stream has been initialized.
503#[cfg(target_os = "macos")]
504pub struct NativeBnnsGumbelMax {
505    _guard: std::sync::MutexGuard<'static, ()>,
506}
507
508#[cfg(target_os = "macos")]
509static NATIVE_BNNS_GUMBEL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
510
511#[cfg(target_os = "macos")]
512impl NativeBnnsGumbelMax {
513    /// Initialize the native BNNS stream and retain exclusive RLX access.
514    pub fn new(seed: u64) -> Option<Self> {
515        #[link(name = "Accelerate", kind = "framework")]
516        unsafe extern "C" {
517            fn BNNSInitGumbel(seed: u64);
518        }
519
520        let guard = NATIVE_BNNS_GUMBEL_LOCK.lock().ok()?;
521        // SAFETY: the private function takes one scalar and updates the
522        // process-global Gumbel state synchronously.
523        unsafe { BNNSInitGumbel(seed) };
524        Some(Self { _guard: guard })
525    }
526
527    /// Sample one class through `BNNSDirectApplyActivationBatch`.
528    pub fn argmax(&mut self, logits: &[f32], alpha: f32, beta: f32) -> Option<usize> {
529        native_bnns_gumbel_max_argmax_impl(logits, alpha, beta)
530    }
531}
532
533#[cfg(not(target_os = "macos"))]
534pub struct NativeBnnsGumbelMax;
535
536#[cfg(not(target_os = "macos"))]
537impl NativeBnnsGumbelMax {
538    pub fn new(_seed: u64) -> Option<Self> {
539        None
540    }
541
542    pub fn argmax(&mut self, _logits: &[f32], _alpha: f32, _beta: f32) -> Option<usize> {
543        None
544    }
545}
546
547#[cfg(target_os = "macos")]
548fn native_bnns_gumbel_max_argmax_impl(logits: &[f32], alpha: f32, beta: f32) -> Option<usize> {
549    #[repr(C, align(64))]
550    #[derive(Clone, Copy)]
551    struct AlignedF32Block([f32; 16]);
552
553    #[repr(C)]
554    struct BnnsNdArrayDescriptor {
555        flags: u32,
556        layout: u32,
557        size: [usize; 8],
558        stride: [usize; 8],
559        data: *mut core::ffi::c_void,
560        data_type: u32,
561        table_data: *mut core::ffi::c_void,
562        table_data_type: u32,
563        data_scale: f32,
564        data_bias: f32,
565    }
566
567    #[repr(C)]
568    struct BnnsActivation {
569        function: u32,
570        alpha: f32,
571        beta: f32,
572        iscale: i32,
573        ioffset: i32,
574        ishift: i32,
575        iscale_per_channel: *const i32,
576        ioffset_per_channel: *const i32,
577        ishift_per_channel: *const i32,
578    }
579
580    #[repr(C)]
581    struct BnnsLayerParametersActivation {
582        i_desc: BnnsNdArrayDescriptor,
583        o_desc: BnnsNdArrayDescriptor,
584        activation: BnnsActivation,
585        axis_flags: u32,
586    }
587
588    #[link(name = "Accelerate", kind = "framework")]
589    unsafe extern "C" {
590        fn BNNSDirectApplyActivationBatch(
591            layer_params: *const BnnsLayerParametersActivation,
592            filter_params: *const core::ffi::c_void,
593            batch_size: usize,
594            in_stride: usize,
595            out_stride: usize,
596        ) -> i32;
597    }
598
599    const BNNS_LAYOUT_VECTOR: u32 = 0x1_0000;
600    const BNNS_FLOAT32: u32 = 0x1_0000 | 32;
601    const BNNS_ACTIVATION_GUMBEL_MAX: u32 = 14;
602
603    fn desc(data: *mut f32, n: usize) -> BnnsNdArrayDescriptor {
604        BnnsNdArrayDescriptor {
605            flags: 0,
606            layout: BNNS_LAYOUT_VECTOR,
607            size: [n, 0, 0, 0, 0, 0, 0, 0],
608            stride: [0; 8],
609            data: data.cast(),
610            data_type: BNNS_FLOAT32,
611            table_data: core::ptr::null_mut(),
612            table_data_type: 0,
613            data_scale: 1.0,
614            data_bias: 0.0,
615        }
616    }
617
618    if logits.is_empty() {
619        return None;
620    }
621    // BNNS partitions this activation according to pointer alignment.
622    // Espresso's activation buffers are 64-byte aligned.
623    let mut input = vec![AlignedF32Block([0.0; 16]); logits.len().div_ceil(16)];
624    for (i, &value) in logits.iter().enumerate() {
625        input[i / 16].0[i % 16] = value;
626    }
627    let mut output = AlignedF32Block([0.0; 16]);
628    let params = BnnsLayerParametersActivation {
629        i_desc: desc(input.as_mut_ptr().cast(), logits.len()),
630        o_desc: desc(output.0.as_mut_ptr(), 1),
631        activation: BnnsActivation {
632            function: BNNS_ACTIVATION_GUMBEL_MAX,
633            alpha,
634            beta,
635            iscale: 0,
636            ioffset: 0,
637            ishift: 0,
638            iscale_per_channel: core::ptr::null(),
639            ioffset_per_channel: core::ptr::null(),
640            ishift_per_channel: core::ptr::null(),
641        },
642        axis_flags: 0,
643    };
644    // SAFETY: descriptors point to live contiguous f32 buffers for this
645    // synchronous call and mirror the SDK C layout.
646    let rc =
647        unsafe { BNNSDirectApplyActivationBatch(&params, core::ptr::null(), 1, logits.len(), 1) };
648    let output = output.0[0];
649    if rc != 0 || !output.is_finite() || output < 0.0 {
650        return None;
651    }
652    let index = output as usize;
653    (index < logits.len() && output == index as f32).then_some(index)
654}
655
656/// Byte length of `BNNSRandomGeneratorGetState` for AES-CTR generators.
657pub const BNNS_AES_CTR_STATE_SIZE: usize = 40;
658
659/// Extract the `u64` seed from a BNNS AES-CTR generator state blob.
660///
661/// Layout (observed on macOS): bytes `[0..8]` and `[8..16]` are the seed's
662/// little-endian bytes repeated (AES-128 key = `seed‖seed`); `[16..32]` holds
663/// the big-endian counter / residual block; `[32..40]` is the stream cursor.
664pub fn bnns_seed_from_generator_state(state: &[u8]) -> Option<u64> {
665    if state.len() < 8 {
666        return None;
667    }
668    Some(u64::from_le_bytes(state[..8].try_into().ok()?))
669}
670
671/// `true` when `state` still describes a freshly constructed generator
672/// (no uniforms drawn yet).
673pub fn bnns_generator_state_is_fresh(state: &[u8]) -> bool {
674    state.len() >= BNNS_AES_CTR_STATE_SIZE
675        && state[16..BNNS_AES_CTR_STATE_SIZE].iter().all(|&b| b == 0)
676}
677
678/// OS CSPRNG `u64` used the same way `BNNSCreateRandomGenerator` seeds AES-CTR.
679///
680/// This matches Apple's *contract* (entropy → `WithSeed`), not a specific
681/// syscall sequence inside Accelerate.
682pub fn bnns_entropy_seed() -> u64 {
683    let mut bytes = [0u8; 8];
684    fill_os_entropy(&mut bytes);
685    u64::from_le_bytes(bytes)
686}
687
688#[cfg(unix)]
689fn fill_os_entropy(out: &mut [u8]) {
690    // getentropy(2) is available on macOS 10.12+ / modern Linux.
691    #[link(name = "c")]
692    unsafe extern "C" {
693        fn getentropy(buf: *mut core::ffi::c_void, buflen: usize) -> i32;
694    }
695    let rc = unsafe { getentropy(out.as_mut_ptr().cast(), out.len()) };
696    if rc == 0 {
697        return;
698    }
699    // Extremely unlikely; fall back to a mixed time stamp rather than panic.
700    let t = std::time::SystemTime::now()
701        .duration_since(std::time::UNIX_EPOCH)
702        .map(|d| d.as_nanos() as u64)
703        .unwrap_or(0x0C0F_FEE0_0D15_EA5E);
704    out.copy_from_slice(&t.to_le_bytes()[..out.len().min(8)]);
705}
706
707#[cfg(not(unix))]
708fn fill_os_entropy(out: &mut [u8]) {
709    let t = std::time::SystemTime::now()
710        .duration_since(std::time::UNIX_EPOCH)
711        .map(|d| d.as_nanos() as u64)
712        .unwrap_or(0x0C0F_FEE0_0D15_EA5E);
713    let b = t.to_le_bytes();
714    for (i, slot) in out.iter_mut().enumerate() {
715        *slot = b[i % 8];
716    }
717}
718
719/// Convert one AES stream word exactly as `BNNSRandomFillUniformFloat` does.
720///
721/// The cast can round values near `u32::MAX` to 2^32, so the result may be
722/// exactly `1.0`; callers applying logarithms must clamp as appropriate.
723pub fn bnns_uniform_f32_from_u32(word: u32) -> f32 {
724    (word as f32) * (1.0 / 4_294_967_296.0)
725}
726
727/// Convert four consecutive AES bytes to BNNS's uniform float.
728pub fn bnns_uniform_f32_from_bytes(bytes: [u8; 4]) -> f32 {
729    bnns_uniform_f32_from_u32(u32::from_le_bytes(bytes))
730}
731
732/// Convert four consecutive AES bytes through BNNS's uniform mapping and the
733/// Espresso Gumbel inverse CDF.
734pub fn bnns_gumbel01_from_bytes(bytes: [u8; 4]) -> f32 {
735    gumbel01_from_uniform(bnns_uniform_f32_from_bytes(bytes))
736}
737
738/// Map an AES word into `[lo, hi)` via Lemire multiply-high.
739///
740/// Requires `lo < hi`. When `lo >= hi`, returns `lo`.
741pub fn bnns_uniform_int_from_u32(word: u32, lo: i64, hi: i64) -> i64 {
742    if lo >= hi {
743        return lo;
744    }
745    let span = (hi - lo) as u64;
746    let offset = ((word as u64).wrapping_mul(span)) >> 32;
747    lo.wrapping_add(offset as i64)
748}
749
750/// BNNS activation Gumbel: `−log(−log(α·U+β)+β)`.
751///
752/// When `β == 0`, `U` is clamped away from the singular endpoints.
753pub fn bnns_gumbel_from_uniform(u: f32, alpha: f32, beta: f32) -> f32 {
754    let u = if beta == 0.0 {
755        u.clamp(GUMBEL_UNIFORM_EPS, 1.0 - GUMBEL_UNIFORM_EPS)
756    } else {
757        u
758    };
759    let inner = alpha.mul_add(u, beta).max(f32::MIN_POSITIVE);
760    -((-inner.ln()) + beta).ln()
761}
762
763/// Inverse-CDF categorical index from one BNNS uniform in `[0, 1)`.
764pub fn bnns_categorical_from_uniform(u: f32, probabilities: &[f32]) -> usize {
765    if probabilities.is_empty() {
766        return 0;
767    }
768    let mut cum = 0.0f32;
769    for (i, &p) in probabilities.iter().enumerate() {
770        cum += p;
771        if u < cum {
772            return i;
773        }
774    }
775    probabilities.len() - 1
776}
777
778fn bnns_normalize_probs(probabilities: &[f32], log_probabilities: bool) -> Vec<f32> {
779    if probabilities.is_empty() {
780        return Vec::new();
781    }
782    if log_probabilities {
783        let max = probabilities
784            .iter()
785            .copied()
786            .fold(f32::NEG_INFINITY, f32::max);
787        let mut exps: Vec<f32> = probabilities.iter().map(|&p| (p - max).exp()).collect();
788        let sum: f32 = exps.iter().sum();
789        if sum > 0.0 {
790            for p in &mut exps {
791                *p /= sum;
792            }
793        }
794        exps
795    } else {
796        let sum: f32 = probabilities.iter().sum();
797        if sum > 0.0 {
798            probabilities.iter().map(|&p| p / sum).collect()
799        } else {
800            probabilities.to_vec()
801        }
802    }
803}
804
805/// Clamp used by WaveRNN / Espresso `gumbel_max` before the Gumbel ICDF.
806pub const GUMBEL_UNIFORM_EPS: f32 = 1e-6;
807
808/// `Gumbel(0,1)` from a uniform sample in `[0, 1)`.
809pub fn gumbel01_from_uniform(u: f32) -> f32 {
810    let u = u.clamp(GUMBEL_UNIFORM_EPS, 1.0 - GUMBEL_UNIFORM_EPS);
811    -(-u.ln()).ln()
812}
813
814/// `argmax_i(logit_i + scale * g_i)` for caller-supplied Gumbel draws.
815pub fn gumbel_max_argmax_with<F>(logits: &[f32], scale: f32, mut gumbel: F) -> usize
816where
817    F: FnMut() -> f32,
818{
819    if logits.is_empty() {
820        return 0;
821    }
822    let scale = scale.max(1e-8);
823    let mut best = 0usize;
824    let mut best_v = f32::NEG_INFINITY;
825    for (i, &logit) in logits.iter().enumerate() {
826        let v = logit + scale * gumbel();
827        if v > best_v {
828            best_v = v;
829            best = i;
830        }
831    }
832    best
833}
834
835/// Plain argmax (Espresso `gumbel_max` with `eps = 0` / greedy).
836pub fn argmax_f32(logits: &[f32]) -> usize {
837    logits
838        .iter()
839        .enumerate()
840        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
841        .map(|(i, _)| i)
842        .unwrap_or(0)
843}
844
845/// C++11 `std::default_random_engine` on libstdc++/libc++ (`minstd_rand0`).
846#[derive(Debug, Clone, Copy)]
847struct MinstdRand0 {
848    state: u32,
849}
850
851impl MinstdRand0 {
852    const A: u32 = 48_271;
853    const M: u32 = 2_147_483_647;
854
855    fn new(seed: u32) -> Self {
856        Self {
857            state: seed % Self::M,
858        }
859    }
860
861    fn next_u32(&mut self) -> u32 {
862        self.state = ((self.state as u64 * Self::A as u64) % Self::M as u64) as u32;
863        self.state
864    }
865
866    /// Uniform in `[0, 1)` matching ORT's `RealType(g()) / (g.max() - g.min())`.
867    fn unit_f32(&mut self) -> f32 {
868        self.next_u32() as f32 / (Self::M - 1) as f32
869    }
870}
871
872/// C++ `std::normal_distribution<float>` (polar method, caches spare sample).
873#[derive(Debug, Clone, Copy)]
874struct StdNormalDist {
875    mean: f32,
876    scale: f32,
877    spare: f32,
878    has_spare: bool,
879}
880
881impl StdNormalDist {
882    fn new(mean: f32, scale: f32) -> Self {
883        Self {
884            mean,
885            scale,
886            spare: 0.0,
887            has_spare: false,
888        }
889    }
890
891    fn sample(&mut self, eng: &mut MinstdRand0) -> f32 {
892        if self.has_spare {
893            self.has_spare = false;
894            return self.spare;
895        }
896        loop {
897            let u1 = 2.0 * eng.unit_f32() - 1.0;
898            let u2 = 2.0 * eng.unit_f32() - 1.0;
899            let s = u1 * u1 + u2 * u2;
900            if s >= 1.0 || s == 0.0 {
901                continue;
902            }
903            let factor = (-2.0 * s.ln() / s).sqrt();
904            self.spare = u2 * factor * self.scale + self.mean;
905            self.has_spare = true;
906            return u1 * factor * self.scale + self.mean;
907        }
908    }
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    #[test]
916    fn same_seed_same_sequence() {
917        let mut a = Philox4x32::new(0x1234_5678);
918        let mut b = Philox4x32::new(0x1234_5678);
919        for _ in 0..256 {
920            assert_eq!(a.next_u32(), b.next_u32());
921        }
922    }
923
924    #[test]
925    fn different_seed_different_sequence() {
926        let mut a = Philox4x32::new(1);
927        let mut b = Philox4x32::new(2);
928        let mut diffs = 0usize;
929        for _ in 0..16 {
930            if a.next_u32() != b.next_u32() {
931                diffs += 1;
932            }
933        }
934        assert!(
935            diffs >= 14,
936            "two distinct seeds should disagree on >=14/16 samples"
937        );
938    }
939
940    #[test]
941    fn next_f32_in_unit_interval() {
942        let mut r = Philox4x32::new(42);
943        for _ in 0..1000 {
944            let v = r.next_f32();
945            assert!((0.0..1.0).contains(&v), "{v} not in [0, 1)");
946        }
947    }
948
949    #[test]
950    fn fill_uniform_is_deterministic() {
951        let mut r1 = Philox4x32::new(7);
952        let mut r2 = Philox4x32::new(7);
953        let mut a = vec![0f32; 64];
954        let mut b = vec![0f32; 64];
955        r1.fill_uniform(&mut a);
956        r2.fill_uniform(&mut b);
957        assert_eq!(a, b);
958    }
959
960    #[test]
961    fn normal_mean_is_near_zero() {
962        let mut r = Philox4x32::new(123);
963        let n = 10_000;
964        let mut sum = 0f32;
965        for _ in 0..n {
966            sum += r.normal();
967        }
968        let mean = sum / n as f32;
969        assert!(mean.abs() < 0.1, "mean {mean} too far from 0");
970    }
971
972    #[test]
973    fn zero_backend_fills_zeros() {
974        let mut out = vec![1.0; 8];
975        fill_normal_like(&mut out, 0.0, 1.0, RngOptions::zero(), 0xABC, None);
976        assert!(out.iter().all(|&v| v == 0.0));
977    }
978
979    #[test]
980    fn philox_backend_is_deterministic() {
981        let opts = RngOptions::philox(99);
982        let mut a = vec![0f32; 32];
983        let mut b = vec![0f32; 32];
984        fill_normal_like(&mut a, 0.0, 0.5, opts, 123, None);
985        fill_normal_like(&mut b, 0.0, 0.5, opts, 123, None);
986        assert_eq!(a, b);
987    }
988
989    #[test]
990    fn ort_backend_is_deterministic() {
991        let opts = RngOptions::ort(7);
992        let mut a = vec![0f32; 64];
993        let mut b = vec![0f32; 64];
994        fill_normal_like(&mut a, 0.1, 2.0, opts, 555, None);
995        fill_normal_like(&mut b, 0.1, 2.0, opts, 555, None);
996        assert_eq!(a, b);
997    }
998
999    #[test]
1000    fn backends_disagree() {
1001        let mut philox = vec![0f32; 16];
1002        let mut ort = vec![0f32; 16];
1003        fill_normal_like(&mut philox, 0.0, 1.0, RngOptions::philox(42), 1, None);
1004        fill_normal_like(&mut ort, 0.0, 1.0, RngOptions::ort(42), 1, None);
1005        assert_ne!(philox, ort);
1006    }
1007
1008    #[test]
1009    fn gumbel_max_is_deterministic() {
1010        let logits = [1.0f32, 5.0, 2.0, 3.0];
1011        let mut a = Philox4x32::new(7);
1012        let mut b = Philox4x32::new(7);
1013        assert_eq!(
1014            a.gumbel_max_argmax(&logits, 0.01),
1015            b.gumbel_max_argmax(&logits, 0.01)
1016        );
1017    }
1018
1019    #[test]
1020    fn gumbel_max_zero_scale_is_argmax() {
1021        let logits = [1.0f32, 9.0, 2.0];
1022        // With tiny scale the peak still wins almost always; argmax helper is exact.
1023        assert_eq!(argmax_f32(&logits), 1);
1024        let mut rng = Philox4x32::new(1);
1025        // Very small scale ≈ greedy on peaked logits.
1026        assert_eq!(rng.gumbel_max_argmax(&logits, 1e-8), 1);
1027    }
1028
1029    #[test]
1030    fn gumbel01_from_uniform_matches_icdf() {
1031        let g = gumbel01_from_uniform(0.5);
1032        let expected = -((-0.5f32.ln()).ln());
1033        assert!((g - expected).abs() < 1e-6);
1034    }
1035
1036    #[test]
1037    fn bnns_byte_mapping_uses_little_endian_full_word() {
1038        assert_eq!(
1039            bnns_uniform_f32_from_bytes([0x38, 0xc1, 0x16, 0xcf]).to_bits(),
1040            0x3f4f_16c1
1041        );
1042        assert_eq!(bnns_uniform_f32_from_u32(u32::MAX), 1.0);
1043    }
1044
1045    #[test]
1046    fn bnns_gumbel_uses_private_uniform_mapping() {
1047        let bytes = [0x38, 0xc1, 0x16, 0xcf];
1048        assert_eq!(
1049            bnns_gumbel01_from_bytes(bytes),
1050            gumbel01_from_uniform(f32::from_bits(0x3f4f_16c1))
1051        );
1052    }
1053
1054    #[test]
1055    fn bnns_batch_fill_matches_scalar_stream_across_block_edges() {
1056        // Lengths that sit before / on / after AES-block boundaries (4 floats).
1057        let lengths = [1usize, 3, 4, 5, 7, 8, 9, 15, 16, 17, 63, 64, 65, 257, 1024];
1058        let seeds = [0u64, 1, 42, 0x1234_5678_9abc_def0];
1059        let ranges = [(0.0, 1.0), (-2.0, 3.0), (0.5, 0.5), (-1e3, 1e3)];
1060
1061        for &seed in &seeds {
1062            for &n in &lengths {
1063                for &(lo, hi) in &ranges {
1064                    let mut scalar = BnnsAesCtr128::new(seed);
1065                    let mut batch = BnnsAesCtr128::new(seed);
1066                    let mut from_scalar = vec![0.0; n];
1067                    let mut from_batch = vec![0.0; n];
1068                    for v in &mut from_scalar {
1069                        *v = scalar.uniform(lo, hi);
1070                    }
1071                    batch.fill_uniform(&mut from_batch, lo, hi);
1072                    assert_eq!(
1073                        from_batch, from_scalar,
1074                        "seed={seed} n={n} range=[{lo},{hi}]"
1075                    );
1076                }
1077            }
1078        }
1079    }
1080
1081    #[test]
1082    fn bnns_batch_gumbel_matches_uniform_then_icdf() {
1083        let n = 4096;
1084        let seed = 0xA5A5_5A5A_C3C3_3C3C;
1085        let mut u_rng = BnnsAesCtr128::new(seed);
1086        let mut g_rng = BnnsAesCtr128::new(seed);
1087        let mut uniforms = vec![0.0; n];
1088        let mut gumbels = vec![0.0; n];
1089        u_rng.fill_uniform(&mut uniforms, 0.0, 1.0);
1090        g_rng.fill_gumbel01(&mut gumbels);
1091        for (i, (&u, &g)) in uniforms.iter().zip(gumbels.iter()).enumerate() {
1092            assert_eq!(g, gumbel01_from_uniform(u), "gumbel mismatch at sample {i}");
1093        }
1094    }
1095
1096    #[test]
1097    fn bnns_batch_is_deterministic_and_seed_sensitive() {
1098        let n = 2048;
1099        let mut a = vec![0.0; n];
1100        let mut b = vec![0.0; n];
1101        let mut c = vec![0.0; n];
1102        BnnsAesCtr128::new(99).fill_uniform(&mut a, -1.0, 2.0);
1103        BnnsAesCtr128::new(99).fill_uniform(&mut b, -1.0, 2.0);
1104        BnnsAesCtr128::new(100).fill_uniform(&mut c, -1.0, 2.0);
1105        assert_eq!(a, b);
1106        assert_ne!(a, c);
1107        // Distinct seeds should disagree on nearly all samples.
1108        let diffs = a.iter().zip(c.iter()).filter(|(x, y)| x != y).count();
1109        assert!(diffs > n * 9 / 10, "only {diffs}/{n} samples differed");
1110    }
1111
1112    #[test]
1113    fn bnns_captured_apple_vectors_seed_42() {
1114        // Anchors captured from macOS BNNSRandomFillUniformFloat.
1115        let unit = [
1116            0x3f4f_16c1,
1117            0x3f17_8337,
1118            0x3ef1_8cae,
1119            0x3f71_4573,
1120            0x3f73_349f,
1121            0x3f24_dd54,
1122            0x3cdb_6bcf,
1123            0x3d7c_eb6a,
1124        ];
1125        let ranged = [
1126            0x4002_dc71,
1127            0x3f75_9013,
1128            0x3eb7_bf66,
1129            0x402d_96d0,
1130            0x4030_01c7,
1131            0x3f9c_2952,
1132            0xbfee_db94,
1133            0xbfd8_7b37,
1134        ];
1135        let mut unit_out = [0.0; 8];
1136        let mut ranged_out = [0.0; 8];
1137        BnnsAesCtr128::new(42).fill_uniform(&mut unit_out, 0.0, 1.0);
1138        BnnsAesCtr128::new(42).fill_uniform(&mut ranged_out, -2.0, 3.0);
1139        assert_eq!(unit_out.map(f32::to_bits), unit);
1140        assert_eq!(ranged_out.map(f32::to_bits), ranged);
1141    }
1142
1143    #[test]
1144    fn bnns_from_entropy_is_seed_sensitive() {
1145        let mut a = [0.0; 32];
1146        let mut b = [0.0; 32];
1147        BnnsAesCtr128::from_entropy().fill_uniform(&mut a, 0.0, 1.0);
1148        BnnsAesCtr128::from_entropy().fill_uniform(&mut b, 0.0, 1.0);
1149        // Astronomically unlikely to collide for 32 independent floats.
1150        assert_ne!(a, b);
1151    }
1152
1153    #[test]
1154    fn bnns_seed_from_fresh_state_roundtrip() {
1155        let seed: u64 = 0x0123_4567_89ab_cdef;
1156        let mut state = [0u8; BNNS_AES_CTR_STATE_SIZE];
1157        let le = seed.to_le_bytes();
1158        state[..8].copy_from_slice(&le);
1159        state[8..16].copy_from_slice(&le);
1160        assert!(bnns_generator_state_is_fresh(&state));
1161        assert_eq!(bnns_seed_from_generator_state(&state), Some(seed));
1162        let mut from_state = [0.0; 8];
1163        let mut from_seed = [0.0; 8];
1164        BnnsAesCtr128::try_from_fresh_generator_state(&state)
1165            .unwrap()
1166            .fill_uniform(&mut from_state, 0.0, 1.0);
1167        BnnsAesCtr128::new(seed).fill_uniform(&mut from_seed, 0.0, 1.0);
1168        assert_eq!(from_state, from_seed);
1169        // Advanced cursor → reject.
1170        state[32] = 4;
1171        assert!(!bnns_generator_state_is_fresh(&state));
1172        assert!(BnnsAesCtr128::try_from_fresh_generator_state(&state).is_none());
1173    }
1174
1175    #[test]
1176    fn bnns_backend_fill_helpers_are_deterministic() {
1177        let opts = RngOptions::bnns(99);
1178        let mut a = vec![0f32; 64];
1179        let mut b = vec![0f32; 64];
1180        fill_uniform_like(&mut a, -1.0, 2.0, opts, 7, None);
1181        fill_uniform_like(&mut b, -1.0, 2.0, opts, 7, None);
1182        assert_eq!(a, b);
1183        fill_normal_like(&mut a, 0.5, 2.0, opts, 7, None);
1184        fill_normal_like(&mut b, 0.5, 2.0, opts, 7, None);
1185        assert_eq!(a, b);
1186        assert_ne!(a, vec![0.0; 64]);
1187    }
1188
1189    #[test]
1190    fn bnns_normal_reuses_pair_within_fill() {
1191        let mut paired = [0.0; 4];
1192        let mut singles = [0.0; 4];
1193        BnnsAesCtr128::new(42).fill_normal(&mut paired, 0.0, 1.0);
1194        let mut rng = BnnsAesCtr128::new(42);
1195        for v in &mut singles {
1196            *v = rng.normal();
1197        }
1198        // Four scalar `normal()` calls keep the spare across calls; one fill of
1199        // four does the same pairing within the fill.
1200        assert_eq!(paired, singles);
1201    }
1202
1203    #[test]
1204    fn bnns_gumbel_alpha_beta_matches_documented_formula() {
1205        let u = 0.5f32;
1206        let g = bnns_gumbel_from_uniform(u, 2.0, 0.1);
1207        let expected = -((-(2.0 * u + 0.1).ln() + 0.1).ln());
1208        assert!((g - expected).abs() < 1e-6);
1209        // α=1, β=0 recovers Espresso ICDF (with ε clamp).
1210        assert_eq!(
1211            bnns_gumbel_from_uniform(u, 1.0, 0.0),
1212            gumbel01_from_uniform(u)
1213        );
1214    }
1215
1216    #[test]
1217    fn bnns_categorical_matches_inverse_cdf_on_uniforms() {
1218        let probs = [0.1f32, 0.2, 0.3, 0.4];
1219        let n = 256;
1220        let mut uniforms = vec![0.0; n];
1221        let mut cats = vec![0.0; n];
1222        BnnsAesCtr128::new(42).fill_uniform(&mut uniforms, 0.0, 1.0);
1223        BnnsAesCtr128::new(42).fill_categorical(&mut cats, &probs, false);
1224        for (i, (&u, &c)) in uniforms.iter().zip(cats.iter()).enumerate() {
1225            assert_eq!(
1226                c as usize,
1227                bnns_categorical_from_uniform(u, &probs),
1228                "categorical mismatch at {i}"
1229            );
1230        }
1231        let mut log_cats = vec![0.0; n];
1232        let log_probs: Vec<f32> = probs.iter().map(|p| p.ln()).collect();
1233        BnnsAesCtr128::new(42).fill_categorical(&mut log_cats, &log_probs, true);
1234        assert_eq!(cats, log_cats);
1235    }
1236
1237    #[test]
1238    fn bnns_uniform_int_stays_in_half_open_range() {
1239        let mut out = [0i64; 4096];
1240        BnnsAesCtr128::new(42).fill_uniform_int(&mut out, -3, 10);
1241        assert!(out.iter().all(|&v| (-3..10).contains(&v)));
1242        assert_eq!(bnns_uniform_int_from_u32(0, 0, 1), 0);
1243        assert_eq!(bnns_uniform_int_from_u32(u32::MAX, 0, 1), 0);
1244        assert_ne!(
1245            {
1246                let mut a = [0i64; 64];
1247                BnnsAesCtr128::new(1).fill_uniform_int(&mut a, 0, 100);
1248                a
1249            },
1250            {
1251                let mut b = [0i64; 64];
1252                BnnsAesCtr128::new(2).fill_uniform_int(&mut b, 0, 100);
1253                b
1254            }
1255        );
1256    }
1257
1258    #[cfg(target_os = "macos")]
1259    #[test]
1260    fn native_bnns_gumbel_replays_seeded_stream() {
1261        let logits = [0.0f32; 256];
1262        let draw = || {
1263            let mut sampler = NativeBnnsGumbelMax::new(16_807).unwrap();
1264            (0..8)
1265                .map(|_| sampler.argmax(&logits, 1.0, 0.01).unwrap())
1266                .collect::<Vec<_>>()
1267        };
1268        assert_eq!(draw(), draw());
1269    }
1270
1271    #[cfg(target_os = "macos")]
1272    mod apple_bnns_parity {
1273        use super::*;
1274
1275        #[repr(C)]
1276        struct BnnsNdArrayDescriptor {
1277            flags: u32,
1278            layout: u32,
1279            size: [usize; 8],
1280            stride: [usize; 8],
1281            data: *mut core::ffi::c_void,
1282            data_type: u32,
1283            table_data: *mut core::ffi::c_void,
1284            table_data_type: u32,
1285            data_scale: f32,
1286            data_bias: f32,
1287        }
1288
1289        #[link(name = "Accelerate", kind = "framework")]
1290        unsafe extern "C" {
1291            fn BNNSCreateRandomGenerator(
1292                method: u32,
1293                filter_params: *const core::ffi::c_void,
1294            ) -> *mut core::ffi::c_void;
1295            fn BNNSCreateRandomGeneratorWithSeed(
1296                method: u32,
1297                seed: u64,
1298                filter_params: *const core::ffi::c_void,
1299            ) -> *mut core::ffi::c_void;
1300            fn BNNSDestroyRandomGenerator(generator: *mut core::ffi::c_void);
1301            fn BNNSRandomGeneratorStateSize(generator: *mut core::ffi::c_void) -> usize;
1302            fn BNNSRandomGeneratorGetState(
1303                generator: *mut core::ffi::c_void,
1304                size: usize,
1305                state: *mut u8,
1306            ) -> i32;
1307            fn BNNSRandomFillUniformFloat(
1308                generator: *mut core::ffi::c_void,
1309                desc: *mut BnnsNdArrayDescriptor,
1310                a: f32,
1311                b: f32,
1312            ) -> i32;
1313            fn BNNSRandomFillNormalFloat(
1314                generator: *mut core::ffi::c_void,
1315                desc: *mut BnnsNdArrayDescriptor,
1316                mean: f32,
1317                stddev: f32,
1318            ) -> i32;
1319            fn BNNSRandomFillCategoricalFloat(
1320                generator: *mut core::ffi::c_void,
1321                desc: *const BnnsNdArrayDescriptor,
1322                probabilities: *const BnnsNdArrayDescriptor,
1323                log_probabilities: bool,
1324            ) -> i32;
1325        }
1326
1327        const BNNS_METHOD_AES_CTR: u32 = 0;
1328        const BNNS_LAYOUT_VECTOR: u32 = 0x1_0000;
1329        const BNNS_FLOAT32: u32 = 0x1_0000 | 32;
1330
1331        fn apple_desc(data: *mut f32, n: usize) -> BnnsNdArrayDescriptor {
1332            BnnsNdArrayDescriptor {
1333                flags: 0,
1334                layout: BNNS_LAYOUT_VECTOR,
1335                size: [n, 0, 0, 0, 0, 0, 0, 0],
1336                stride: [0; 8],
1337                data: data.cast(),
1338                data_type: BNNS_FLOAT32,
1339                table_data: core::ptr::null_mut(),
1340                table_data_type: 0,
1341                data_scale: 1.0,
1342                data_bias: 0.0,
1343            }
1344        }
1345
1346        fn apple_fill_uniform(seed: u64, out: &mut [f32], lo: f32, hi: f32) {
1347            unsafe {
1348                let generator =
1349                    BNNSCreateRandomGeneratorWithSeed(BNNS_METHOD_AES_CTR, seed, core::ptr::null());
1350                assert!(
1351                    !generator.is_null(),
1352                    "BNNSCreateRandomGeneratorWithSeed failed"
1353                );
1354                let mut desc = apple_desc(out.as_mut_ptr(), out.len());
1355                let rc = BNNSRandomFillUniformFloat(generator, &mut desc, lo, hi);
1356                BNNSDestroyRandomGenerator(generator);
1357                assert_eq!(rc, 0, "BNNSRandomFillUniformFloat failed");
1358            }
1359        }
1360
1361        fn apple_fill_normal(seed: u64, out: &mut [f32], mean: f32, stddev: f32) {
1362            unsafe {
1363                let generator =
1364                    BNNSCreateRandomGeneratorWithSeed(BNNS_METHOD_AES_CTR, seed, core::ptr::null());
1365                assert!(!generator.is_null());
1366                let mut desc = apple_desc(out.as_mut_ptr(), out.len());
1367                let rc = BNNSRandomFillNormalFloat(generator, &mut desc, mean, stddev);
1368                BNNSDestroyRandomGenerator(generator);
1369                assert_eq!(rc, 0, "BNNSRandomFillNormalFloat failed");
1370            }
1371        }
1372
1373        fn apple_fill_categorical(
1374            seed: u64,
1375            out: &mut [f32],
1376            probabilities: &[f32],
1377            log_probabilities: bool,
1378        ) {
1379            unsafe {
1380                let generator =
1381                    BNNSCreateRandomGeneratorWithSeed(BNNS_METHOD_AES_CTR, seed, core::ptr::null());
1382                assert!(!generator.is_null());
1383                let mut probs = probabilities.to_vec();
1384                let mut odesc = apple_desc(out.as_mut_ptr(), out.len());
1385                let mut pdesc = apple_desc(probs.as_mut_ptr(), probs.len());
1386                let rc =
1387                    BNNSRandomFillCategoricalFloat(generator, &odesc, &pdesc, log_probabilities);
1388                let _ = (&mut odesc, &mut pdesc);
1389                BNNSDestroyRandomGenerator(generator);
1390                assert_eq!(rc, 0, "BNNSRandomFillCategoricalFloat failed");
1391            }
1392        }
1393
1394        fn max_ulps(a: &[f32], b: &[f32]) -> u32 {
1395            a.iter()
1396                .zip(b.iter())
1397                .map(|(&x, &y)| x.to_bits().abs_diff(y.to_bits()))
1398                .max()
1399                .unwrap_or(0)
1400        }
1401
1402        #[test]
1403        fn batch_matches_apple_bnns_across_seeds_sizes_ranges() {
1404            let seeds = [
1405                0u64,
1406                1,
1407                42,
1408                7,
1409                0xffff_ffff_ffff_ffff,
1410                0x1234_5678_9abc_def0,
1411                0xa5a5_5a5a_c3c3_3c3c,
1412            ];
1413            let lengths = [1usize, 3, 4, 5, 16, 17, 64, 65, 256, 257, 1024, 4096];
1414            let ranges = [(0.0, 1.0), (-2.0, 3.0), (0.25, 0.75), (-1e2, 1e2)];
1415
1416            for &seed in &seeds {
1417                for &n in &lengths {
1418                    for &(lo, hi) in &ranges {
1419                        let mut apple = vec![0.0; n];
1420                        let mut ours = vec![0.0; n];
1421                        apple_fill_uniform(seed, &mut apple, lo, hi);
1422                        BnnsAesCtr128::new(seed).fill_uniform(&mut ours, lo, hi);
1423                        assert_eq!(
1424                            ours.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1425                            apple.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1426                            "BNNS parity failed seed={seed} n={n} range=[{lo},{hi}]"
1427                        );
1428                    }
1429                }
1430            }
1431        }
1432
1433        #[test]
1434        fn batch_gumbel_from_apple_uniforms() {
1435            let n = 8192;
1436            let seed = 12345u64;
1437            let mut apple_u = vec![0.0; n];
1438            apple_fill_uniform(seed, &mut apple_u, 0.0, 1.0);
1439
1440            let mut ours_g = vec![0.0; n];
1441            BnnsAesCtr128::new(seed).fill_gumbel01(&mut ours_g);
1442
1443            for (i, (&u, &g)) in apple_u.iter().zip(ours_g.iter()).enumerate() {
1444                assert_eq!(
1445                    g.to_bits(),
1446                    gumbel01_from_uniform(u).to_bits(),
1447                    "Gumbel from Apple uniform mismatch at {i}"
1448                );
1449            }
1450        }
1451
1452        #[test]
1453        fn batch_normal_matches_apple_box_muller_pairing() {
1454            // Short fills stay within a few ULPs of Apple's scalar Box–Muller.
1455            // Longer fills still start on the same AES pair (first samples close)
1456            // even when Apple's vector path drifts further in later lanes.
1457            for seed in [42u64, 7, 0xdead_beef] {
1458                for &n in &[1usize, 2, 3, 4] {
1459                    let mut apple = vec![0.0; n];
1460                    let mut ours = vec![0.0; n];
1461                    apple_fill_normal(seed, &mut apple, 0.0, 1.0);
1462                    BnnsAesCtr128::new(seed).fill_normal(&mut ours, 0.0, 1.0);
1463                    assert!(
1464                        max_ulps(&ours, &apple) <= 16,
1465                        "short normal ULP seed={seed} n={n}"
1466                    );
1467                }
1468                let mut apple = vec![0.0; 64];
1469                let mut ours = vec![0.0; 64];
1470                apple_fill_normal(seed, &mut apple, 0.0, 1.0);
1471                BnnsAesCtr128::new(seed).fill_normal(&mut ours, 0.0, 1.0);
1472                assert!(
1473                    max_ulps(&ours[..4], &apple[..4]) <= 16,
1474                    "leading-pair ULP seed={seed}"
1475                );
1476            }
1477        }
1478
1479        #[test]
1480        fn batch_categorical_matches_apple_bit_exact() {
1481            let probs = [0.1f32, 0.2, 0.3, 0.4];
1482            for seed in [42u64, 7, 12345] {
1483                for &n in &[1usize, 8, 16, 64, 256, 1024] {
1484                    let mut apple = vec![0.0; n];
1485                    let mut ours = vec![0.0; n];
1486                    apple_fill_categorical(seed, &mut apple, &probs, false);
1487                    BnnsAesCtr128::new(seed).fill_categorical(&mut ours, &probs, false);
1488                    assert_eq!(ours, apple, "categorical seed={seed} n={n}");
1489
1490                    let log_probs: Vec<f32> = probs.iter().map(|p| p.ln()).collect();
1491                    let mut apple_log = vec![0.0; n];
1492                    let mut ours_log = vec![0.0; n];
1493                    apple_fill_categorical(seed, &mut apple_log, &log_probs, true);
1494                    BnnsAesCtr128::new(seed).fill_categorical(&mut ours_log, &log_probs, true);
1495                    assert_eq!(ours_log, apple_log, "log-categorical seed={seed} n={n}");
1496                }
1497            }
1498        }
1499
1500        #[test]
1501        fn entropy_generator_state_replays_via_seed() {
1502            unsafe {
1503                let generator = BNNSCreateRandomGenerator(BNNS_METHOD_AES_CTR, core::ptr::null());
1504                assert!(!generator.is_null());
1505                let n = BNNSRandomGeneratorStateSize(generator);
1506                assert_eq!(n, BNNS_AES_CTR_STATE_SIZE);
1507                let mut state = vec![0u8; n];
1508                assert_eq!(
1509                    BNNSRandomGeneratorGetState(generator, n, state.as_mut_ptr()),
1510                    0
1511                );
1512                assert!(bnns_generator_state_is_fresh(&state));
1513                let seed = bnns_seed_from_generator_state(&state).unwrap();
1514
1515                let mut apple = [0.0f32; 16];
1516                let mut desc = BnnsNdArrayDescriptor {
1517                    flags: 0,
1518                    layout: BNNS_LAYOUT_VECTOR,
1519                    size: [apple.len(), 0, 0, 0, 0, 0, 0, 0],
1520                    stride: [0; 8],
1521                    data: apple.as_mut_ptr().cast(),
1522                    data_type: BNNS_FLOAT32,
1523                    table_data: core::ptr::null_mut(),
1524                    table_data_type: 0,
1525                    data_scale: 1.0,
1526                    data_bias: 0.0,
1527                };
1528                assert_eq!(
1529                    BNNSRandomFillUniformFloat(generator, &mut desc, 0.0, 1.0),
1530                    0
1531                );
1532                BNNSDestroyRandomGenerator(generator);
1533
1534                let mut ours = [0.0f32; 16];
1535                BnnsAesCtr128::try_from_fresh_generator_state(&state)
1536                    .unwrap()
1537                    .fill_uniform(&mut ours, 0.0, 1.0);
1538                assert_eq!(ours.map(f32::to_bits), apple.map(f32::to_bits));
1539
1540                // Same seed via WithSeed must also match.
1541                let mut with_seed = [0.0f32; 16];
1542                apple_fill_uniform(seed, &mut with_seed, 0.0, 1.0);
1543                assert_eq!(with_seed.map(f32::to_bits), apple.map(f32::to_bits));
1544            }
1545        }
1546    }
1547}