Skip to main content

pounce_minima/
sampling.rs

1//! Start-point sampling for `--minima`: scrambled Sobol' or uniform box
2//! sampling when a finite box is known, else Gaussian jitter around `x0`.
3//! Mirrors `_sample` / `_make_sobol` in `python/pounce/_minima.py`.
4//!
5//! Reproducibility: the PRNG is a seeded `ChaCha8Rng` and the Sobol' source
6//! is `sobol_burley` (Owen-scrambled, matching scipy's
7//! `qmc.Sobol(scramble=True)`), both keyed off `--seed`.
8
9use pounce_common::types::Number;
10use rand::distributions::{Distribution, Standard};
11use rand_chacha::ChaCha8Rng;
12use rand_chacha::rand_core::SeedableRng;
13
14/// Seeded sampler shared across a `--minima` run.
15pub struct Sampler {
16    rng: ChaCha8Rng,
17    /// Sobol' scramble key (derived from the run seed).
18    sobol_seed: u32,
19    /// Next Sobol' point index (advances by one per drawn sample).
20    sobol_index: u32,
21    /// Whether to draw box samples from the Sobol' sequence.
22    sobol: bool,
23}
24
25impl Sampler {
26    pub fn new(seed: u64, sobol: bool) -> Self {
27        Self {
28            rng: ChaCha8Rng::seed_from_u64(seed),
29            // Fold the 64-bit seed into the 32-bit Sobol' scramble key.
30            sobol_seed: (seed ^ (seed >> 32)) as u32,
31            sobol_index: 0,
32            sobol,
33        }
34    }
35
36    /// A uniform deviate in `[0, 1)` (avoids the `gen` keyword/method so the
37    /// code stays edition-2024 clean).
38    pub fn uniform(&mut self) -> f64 {
39        Standard.sample(&mut self.rng)
40    }
41
42    /// A standard-normal deviate via Box–Muller (avoids a `rand_distr` dep).
43    pub fn standard_normal(&mut self) -> f64 {
44        // u1 in (0,1] so ln() is finite.
45        let u1: f64 = 1.0 - self.uniform();
46        let u2: f64 = self.uniform();
47        (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
48    }
49
50    /// Draw a fresh start point.
51    ///
52    /// * `has_box` true ⇒ sample uniformly (Sobol' or PRNG) in `[lo, hi]`.
53    /// * otherwise ⇒ `x0 + jitter · N(0, I)` (the unbounded fallback).
54    pub fn sample(
55        &mut self,
56        x0: &[Number],
57        lo: &[Number],
58        hi: &[Number],
59        has_box: bool,
60        jitter: f64,
61    ) -> Vec<Number> {
62        let n = x0.len();
63        if has_box {
64            let idx = self.sobol_index;
65            self.sobol_index += 1;
66            (0..n)
67                .map(|j| {
68                    let u = if self.sobol {
69                        // One Owen-scrambled Sobol' coordinate per dimension.
70                        // `sobol_burley` has 256 dimensions and panics past
71                        // them; dimension `j` reuses `j mod 256` under a
72                        // scramble seed of its own per block of 256, the
73                        // crate's documented workaround. Block 0 keeps the
74                        // run's seed, so every n <= 256 search draws exactly
75                        // the starts it always did.
76                        let (block, dim) = (j as u32 / NUM_DIMS, j as u32 % NUM_DIMS);
77                        sobol_burley::sample(idx, dim, block_seed(self.sobol_seed, block)) as f64
78                    } else {
79                        self.uniform()
80                    };
81                    lo[j] + (hi[j] - lo[j]) * u
82                })
83                .collect()
84        } else {
85            (0..n)
86                .map(|j| x0[j] + jitter * self.standard_normal())
87                .collect()
88        }
89    }
90
91    /// `anchor + scale · N(0, I)` — a local perturbation (basinhopping step,
92    /// flooding re-seed, tunnelling outward step). `scale` may be a scalar
93    /// or per-dimension (length-1 ⇒ isotropic).
94    pub fn perturb(&mut self, anchor: &[Number], scale: &[Number]) -> Vec<Number> {
95        anchor
96            .iter()
97            .enumerate()
98            .map(|(j, &a)| {
99                let s = if scale.len() == 1 { scale[0] } else { scale[j] };
100                a + s * self.standard_normal()
101            })
102            .collect()
103    }
104}
105
106/// Dimensions `sobol_burley` provides.
107const NUM_DIMS: u32 = sobol_burley::NUM_DIMENSIONS;
108
109/// The scramble seed for block `block` of [`NUM_DIMS`] dimensions: the run's
110/// own for block 0, a well-mixed derivative of it (splitmix64's finalizer)
111/// for the rest, so blocks are scrambled independently.
112fn block_seed(seed: u32, block: u32) -> u32 {
113    if block == 0 {
114        return seed;
115    }
116    let mut z = ((u64::from(seed) << 32) | u64::from(block)).wrapping_add(0x9E37_79B9_7F4A_7C15);
117    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
118    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
119    (z ^ (z >> 31)) as u32
120}
121
122/// Clip `x` into `[lo, hi]` when a finite box is known (no-op otherwise).
123pub fn clip(x: &mut [Number], lo: &[Number], hi: &[Number], has_box: bool) {
124    if !has_box {
125        return;
126    }
127    for j in 0..x.len() {
128        x[j] = x[j].clamp(lo[j], hi[j]);
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::{NUM_DIMS, Sampler, block_seed};
135
136    /// Past 256 dimensions the draw stays in the box, and the extra blocks
137    /// are not copies of the first (each has its own scramble seed).
138    #[test]
139    fn sobol_sampling_extends_past_256_dimensions() {
140        let n = 3 * NUM_DIMS as usize + 7;
141        let (lo, hi) = (vec![-1.0; n], vec![1.0; n]);
142        let mut s = Sampler::new(5, true);
143        for _ in 0..4 {
144            let x = s.sample(&vec![0.0; n], &lo, &hi, true, 1.0);
145            assert_eq!(x.len(), n);
146            assert!(x.iter().all(|v| (-1.0..=1.0).contains(v)));
147            let b = NUM_DIMS as usize;
148            assert_ne!(x[..b], x[b..2 * b], "block 1 repeats block 0");
149        }
150        assert_eq!(block_seed(42, 0), 42);
151        assert_ne!(block_seed(42, 1), block_seed(42, 2));
152    }
153
154    /// The first 256 dimensions draw what they always drew.
155    #[test]
156    fn the_first_256_dimensions_are_unchanged() {
157        let n = NUM_DIMS as usize;
158        let (lo, hi) = (vec![0.0; n], vec![1.0; n]);
159        let mut s = Sampler::new(9, true);
160        let x = s.sample(&vec![0.0; n], &lo, &hi, true, 1.0);
161        let seed = (9u64 ^ (9u64 >> 32)) as u32;
162        for (j, v) in x.iter().enumerate() {
163            assert_eq!(*v, sobol_burley::sample(0, j as u32, seed) as f64);
164        }
165    }
166}