Skip to main content

sim_lib_numbers_stats/
sampling.rs

1//! Versioned deterministic sampling and bounded space-filling designs.
2
3use core::fmt;
4
5/// Stable identity of a deterministic sampler algorithm.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum SamplerAlgorithm {
8    /// SplitMix64 with the published Steele-Vigna mixing constants, version 1.
9    SplitMix64V1,
10}
11
12/// Canonical, architecture-independent sampler state.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub struct SamplerState {
15    /// Algorithm and stream-version identity.
16    pub algorithm: SamplerAlgorithm,
17    /// Current 64-bit state word.
18    pub state: u64,
19    /// Exact number of generated 64-bit words.
20    pub words_generated: u64,
21    /// Root seed from which this stream was constructed.
22    pub seed: u64,
23    /// Substream label, zero for the root stream.
24    pub stream: u64,
25}
26
27/// Replay receipt captured at an observable sampling boundary.
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub struct SamplerReceipt {
30    /// State sufficient to resume the stream exactly.
31    pub state: SamplerState,
32    /// Optional hard word allowance selected by the caller.
33    pub max_words: Option<u64>,
34}
35
36/// A public deterministic sampler with explicit version, work, state and forks.
37///
38/// `fork(label)` is domain-separated from both the root stream and other labels;
39/// it never consumes its parent. Existing consumers use the root stream and thus
40/// retain their historical SplitMix64-v1 output exactly.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct SeededSampler {
43    state: SamplerState,
44    max_words: Option<u64>,
45}
46
47impl SeededSampler {
48    /// Constructs an unbounded root stream. Algorithms should preflight their
49    /// own exact work or use [`Self::with_max_words`] for refusal.
50    pub fn new(seed: u64) -> Self {
51        Self::from_parts(seed, 0, seed, None)
52    }
53
54    /// Constructs a root stream that refuses generation beyond `max_words`.
55    pub fn with_max_words(seed: u64, max_words: u64) -> Self {
56        Self::from_parts(seed, 0, seed, Some(max_words))
57    }
58
59    fn from_parts(seed: u64, stream: u64, state: u64, max_words: Option<u64>) -> Self {
60        Self {
61            state: SamplerState {
62                algorithm: SamplerAlgorithm::SplitMix64V1,
63                state,
64                words_generated: 0,
65                seed,
66                stream,
67            },
68            max_words,
69        }
70    }
71
72    /// Restores a canonical state and word allowance.
73    pub fn replay(receipt: SamplerReceipt) -> Self {
74        Self {
75            state: receipt.state,
76            max_words: receipt.max_words,
77        }
78    }
79
80    /// Captures a replay receipt.
81    pub fn receipt(&self) -> SamplerReceipt {
82        SamplerReceipt {
83            state: self.state,
84            max_words: self.max_words,
85        }
86    }
87
88    /// Returns an independent, deterministic labeled substream without
89    /// advancing this stream.
90    pub fn fork(&self, label: u64) -> Self {
91        let state = mix(self.state.seed
92            ^ label.wrapping_mul(0xd2b7_4407_b1ce_6e93)
93            ^ 0xa076_1d64_78bd_642f);
94        Self::from_parts(self.state.seed, label, state, self.max_words)
95    }
96
97    /// Produces the next word, refusing when the declared allowance is spent.
98    pub fn try_next_u64(&mut self) -> Result<u64, DesignError> {
99        if self
100            .max_words
101            .is_some_and(|limit| self.state.words_generated >= limit)
102        {
103            return Err(DesignError::WorkLimit {
104                required: self.state.words_generated.saturating_add(1),
105                limit: self.max_words.unwrap_or(0),
106            });
107        }
108        self.state.state = self.state.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
109        self.state.words_generated += 1;
110        Ok(mix(self.state.state))
111    }
112
113    /// Produces the next word on a stream whose work was preflighted.
114    pub(crate) fn next_u64(&mut self) -> u64 {
115        self.try_next_u64()
116            .expect("unbounded or preflighted sampler")
117    }
118
119    /// Maps one word to `[0, 1)` using the stable 53-bit convention.
120    pub fn unit_interval(&mut self) -> f64 {
121        (self.next_u64() >> 11) as f64 * (1.0 / ((1_u64 << 53) as f64))
122    }
123
124    /// Historical modulo mapping used by clustering and HMM fixtures.
125    pub(crate) fn index_modulo(&mut self, length: usize) -> usize {
126        (self.next_u64() % length as u64) as usize
127    }
128
129    /// Historical multiply-high mapping used by bootstrap fixtures.
130    pub(crate) fn index_multiply_high(&mut self, length: usize) -> usize {
131        ((u128::from(self.next_u64()) * length as u128) >> 64) as usize
132    }
133}
134
135fn mix(mut value: u64) -> u64 {
136    value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
137    value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
138    value ^ (value >> 31)
139}
140
141/// Optional reviewed digital scrambling policy.
142#[derive(Clone, Copy, Debug, Eq, PartialEq)]
143pub enum Scramble {
144    /// Preserve the canonical digital net.
145    None,
146    /// Apply a deterministic digital XOR shift from the sampler seed.
147    DigitalShift,
148}
149
150/// Caller-described region intentionally absent from a sweep.
151#[derive(Clone, Debug, PartialEq)]
152pub struct UntestedRegion {
153    /// Stable caller-owned label.
154    pub label: String,
155    /// Human-reviewable reason for exclusion.
156    pub reason: String,
157}
158
159/// Coverage and replay evidence returned with every design.
160#[derive(Clone, Debug, PartialEq)]
161pub struct CoverageEvidence {
162    /// Sequence identity including algorithm version and policy.
163    pub sequence_identity: String,
164    /// Exact zero-based boundary point indices injected by policy.
165    pub boundary_injections: Vec<usize>,
166    /// Pairs `(later, earlier)` for exactly duplicated points.
167    pub duplicates: Vec<(usize, usize)>,
168    /// Per-dimension occupancy counts for every declared stratum.
169    pub stratum_occupancy: Vec<Vec<usize>>,
170    /// Final sampler state when a sampler participated.
171    pub sampler: Option<SamplerReceipt>,
172    /// Caller-supplied excluded-region metadata, preserved verbatim.
173    pub untested_regions: Vec<UntestedRegion>,
174    /// Exact admitted work units.
175    pub work: u64,
176}
177
178/// A generated, reconstructable design and its coverage evidence.
179#[derive(Clone, Debug, PartialEq)]
180pub struct SampleDesign {
181    /// Row-major points in the unit hypercube.
182    pub points: Vec<Vec<f64>>,
183    /// Auditable coverage and replay facts.
184    pub coverage: CoverageEvidence,
185}
186
187/// Latin hypercube request.
188#[derive(Clone, Debug, PartialEq)]
189pub struct LatinHypercubePlan {
190    /// Number of coordinates, bounded to 64.
191    pub dimensions: usize,
192    /// Number of points and strata.
193    pub points: usize,
194    /// Root seed.
195    pub seed: u64,
196    /// Maximum shuffle draws.
197    pub max_work: u64,
198    /// Caller-owned exclusions.
199    pub untested_regions: Vec<UntestedRegion>,
200}
201
202impl LatinHypercubePlan {
203    /// Generates a centered Latin hypercube with one point per stratum in every dimension.
204    pub fn generate(&self) -> Result<SampleDesign, DesignError> {
205        validate_shape(self.dimensions, self.points, 64)?;
206        let required = (self.dimensions as u64)
207            .checked_mul(self.points.saturating_sub(1) as u64)
208            .ok_or(DesignError::Overflow)?;
209        if required > self.max_work {
210            return Err(DesignError::WorkLimit {
211                required,
212                limit: self.max_work,
213            });
214        }
215        let mut sampler = SeededSampler::with_max_words(self.seed, self.max_work);
216        let mut points = vec![vec![0.0; self.dimensions]; self.points];
217        let occupancy = vec![vec![1; self.points]; self.dimensions];
218        for (dimension, _) in occupancy.iter().enumerate() {
219            let mut permutation = (0..self.points).collect::<Vec<_>>();
220            for end in (1..self.points).rev() {
221                let chosen = sampler.index_modulo(end + 1);
222                permutation.swap(end, chosen);
223            }
224            for (row, stratum) in permutation.into_iter().enumerate() {
225                points[row][dimension] = (stratum as f64 + 0.5) / self.points as f64;
226            }
227        }
228        Ok(design(
229            points,
230            format!("latin-hypercube/centered-v1;seed={}", self.seed),
231            vec![],
232            occupancy,
233            Some(sampler.receipt()),
234            self.untested_regions.clone(),
235            required,
236        ))
237    }
238}
239
240/// Sobol base-2 digital-net request. The reviewed direction table is bounded to four dimensions.
241#[derive(Clone, Debug, PartialEq)]
242pub struct SobolPlan {
243    /// Dimensions in `1..=4`.
244    pub dimensions: usize,
245    /// Point count.
246    pub points: usize,
247    /// Number of canonical prefix points to skip.
248    pub skip: u64,
249    /// Scrambling policy.
250    pub scramble: Scramble,
251    /// Seed used only by reviewed scrambling.
252    pub seed: u64,
253    /// Maximum coordinate work.
254    pub max_work: u64,
255    /// Caller-owned exclusions.
256    pub untested_regions: Vec<UntestedRegion>,
257}
258
259impl SobolPlan {
260    /// Generates the requested bounded Sobol prefix.
261    pub fn generate(&self) -> Result<SampleDesign, DesignError> {
262        validate_shape(self.dimensions, self.points, 4)?;
263        let end = self
264            .skip
265            .checked_add(self.points as u64)
266            .ok_or(DesignError::Overflow)?;
267        if end > u32::MAX as u64 {
268            return Err(DesignError::UnsupportedPoint { point: end });
269        }
270        let required = (self.dimensions as u64)
271            .checked_mul(self.points as u64)
272            .ok_or(DesignError::Overflow)?;
273        if required > self.max_work {
274            return Err(DesignError::WorkLimit {
275                required,
276                limit: self.max_work,
277            });
278        }
279        let mut sampler = SeededSampler::new(self.seed);
280        let shifts = (0..self.dimensions)
281            .map(|_| {
282                if self.scramble == Scramble::DigitalShift {
283                    sampler.next_u64()
284                } else {
285                    0
286                }
287            })
288            .collect::<Vec<_>>();
289        let mut points = Vec::with_capacity(self.points);
290        for index in self.skip..end {
291            let gray = index ^ (index >> 1);
292            let mut row = Vec::with_capacity(self.dimensions);
293            for (dimension, shift) in shifts.iter().copied().enumerate() {
294                let mut bits = 0_u64;
295                for bit in 0..32 {
296                    if gray & (1_u64 << bit) != 0 {
297                        bits ^= direction(dimension, bit);
298                    }
299                }
300                row.push(((bits ^ shift) >> 11) as f64 * (1.0 / ((1_u64 << 53) as f64)));
301            }
302            points.push(row);
303        }
304        let receipt = (self.scramble == Scramble::DigitalShift).then(|| sampler.receipt());
305        Ok(design(
306            points,
307            format!(
308                "sobol/joe-kuo-reviewed-4d-v1;skip={};scramble={:?};seed={}",
309                self.skip, self.scramble, self.seed
310            ),
311            vec![],
312            vec![],
313            receipt,
314            self.untested_regions.clone(),
315            required,
316        ))
317    }
318}
319
320// Bratley-Fox direction recurrence for dimensions 1..4: (s, a, m_i).
321fn direction(dimension: usize, bit: usize) -> u64 {
322    if dimension == 0 {
323        return 1_u64 << (63 - bit);
324    }
325    let (s, a, initial): (usize, u32, &[u32]) = match dimension {
326        1 => (1, 0, &[1]),
327        2 => (2, 1, &[1, 3]),
328        3 => (3, 1, &[1, 3, 1]),
329        _ => unreachable!(),
330    };
331    let mut values = [0_u64; 32];
332    for index in 0..s {
333        values[index] = (initial[index] as u64) << (63 - index);
334    }
335    for index in s..=bit {
336        let mut value = values[index - s] ^ (values[index - s] >> s);
337        for k in 1..s {
338            if ((a >> (s - 1 - k)) & 1) != 0 {
339                value ^= values[index - k];
340            }
341        }
342        values[index] = value;
343    }
344    values[bit]
345}
346
347/// Boundary-injection wrapper for any already generated unit-cube design.
348#[derive(Clone, Debug, PartialEq)]
349pub struct SweepPlan {
350    /// Whether to prepend the all-zero corner.
351    pub inject_lower_boundary: bool,
352    /// Whether to append the all-one corner.
353    pub inject_upper_boundary: bool,
354    /// Caller-owned exclusions.
355    pub untested_regions: Vec<UntestedRegion>,
356}
357
358impl SweepPlan {
359    /// Injects requested exact boundaries and recomputes duplicate evidence.
360    pub fn apply(&self, mut design: SampleDesign) -> SampleDesign {
361        let dimensions = design.points.first().map_or(0, Vec::len);
362        let mut boundaries = Vec::new();
363        if self.inject_lower_boundary {
364            design.points.insert(0, vec![0.0; dimensions]);
365            boundaries.push(0);
366        }
367        if self.inject_upper_boundary {
368            boundaries.push(design.points.len());
369            design.points.push(vec![1.0; dimensions]);
370        }
371        design.coverage.boundary_injections = boundaries;
372        design.coverage.duplicates = duplicates(&design.points);
373        design
374            .coverage
375            .untested_regions
376            .extend(self.untested_regions.clone());
377        design
378            .coverage
379            .sequence_identity
380            .push_str(";sweep-boundaries-v1");
381        design
382    }
383}
384
385fn validate_shape(
386    dimensions: usize,
387    points: usize,
388    max_dimensions: usize,
389) -> Result<(), DesignError> {
390    if dimensions == 0 || dimensions > max_dimensions {
391        return Err(DesignError::UnsupportedDimension {
392            requested: dimensions,
393            maximum: max_dimensions,
394        });
395    }
396    if points == 0 {
397        return Err(DesignError::InvalidPointCount);
398    }
399    Ok(())
400}
401fn design(
402    points: Vec<Vec<f64>>,
403    identity: String,
404    boundaries: Vec<usize>,
405    occupancy: Vec<Vec<usize>>,
406    sampler: Option<SamplerReceipt>,
407    untested_regions: Vec<UntestedRegion>,
408    work: u64,
409) -> SampleDesign {
410    let duplicates = duplicates(&points);
411    SampleDesign {
412        points,
413        coverage: CoverageEvidence {
414            sequence_identity: identity,
415            boundary_injections: boundaries,
416            duplicates,
417            stratum_occupancy: occupancy,
418            sampler,
419            untested_regions,
420            work,
421        },
422    }
423}
424fn duplicates(points: &[Vec<f64>]) -> Vec<(usize, usize)> {
425    let mut result = Vec::new();
426    for later in 0..points.len() {
427        if let Some(earlier) = (0..later).find(|&earlier| points[earlier] == points[later]) {
428            result.push((later, earlier));
429        }
430    }
431    result
432}
433
434/// Fail-closed design and sampler errors.
435#[derive(Clone, Debug, Eq, PartialEq)]
436pub enum DesignError {
437    /// Dimension exceeds the reviewed table or is zero.
438    UnsupportedDimension {
439        /// Requested dimension.
440        requested: usize,
441        /// Maximum reviewed dimension.
442        maximum: usize,
443    },
444    /// Zero points were requested.
445    InvalidPointCount,
446    /// Skip plus point count exceeds the supported prefix.
447    UnsupportedPoint {
448        /// First unsupported point index.
449        point: u64,
450    },
451    /// Exact work exceeds policy.
452    WorkLimit {
453        /// Required work.
454        required: u64,
455        /// Allowed work.
456        limit: u64,
457    },
458    /// Size arithmetic overflowed.
459    Overflow,
460}
461impl fmt::Display for DesignError {
462    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
463        write!(f, "{self:?}")
464    }
465}
466impl std::error::Error for DesignError {}