Skip to main content

latin_sampler/
sampler.rs

1use crate::LatinSquare;
2use crate::jacobson_matthews::JMState;
3use rand::Rng;
4
5/// Parameters for the MCMC sampler.
6#[derive(Debug, Clone)]
7pub struct SamplerParams {
8    /// Burn-in steps to reach equilibrium from initial cyclic state.
9    ///
10    /// This is the number of MCMC steps (including do-nothing steps with
11    /// probability `p_do_nothing`). The expected number of actual Jacobson-Matthews
12    /// moves is `burn_in * (1 - p_do_nothing)`.
13    ///
14    /// If `None`, uses n³ as the burn-in value. The mixing time of
15    /// Jacobson-Matthews is empirically observed to be O(n³ log n),
16    /// though not rigorously proven. Using n³ provides good uniformity
17    /// in practice.
18    pub burn_in: Option<u64>,
19    /// Thinning factor: steps between successive samples.
20    ///
21    /// If `None`, uses 3×n² for approximate independence. Based on ACF
22    /// analysis, this achieves IACT τ ≤ 1.10 and |ACF(1)| ≤ 0.05 for all n.
23    pub thinning: Option<u64>,
24    /// Probability of doing nothing (for aperiodicity).
25    pub p_do_nothing: f64,
26}
27
28impl Default for SamplerParams {
29    fn default() -> Self {
30        Self {
31            burn_in: None,  // auto: n³
32            thinning: None, // auto: 3×n²
33            p_do_nothing: 0.01,
34        }
35    }
36}
37
38/// Generates an approximately uniform Latin square of order `n`.
39///
40/// Uses MCMC sampling with the Jacobson-Matthews algorithm for ergodicity.
41/// The output is deterministic given the same seed and parameters.
42///
43/// # Panics
44/// Panics if:
45/// - `n < 2` or `n > 255`
46/// - `p_do_nothing` is not in `[0.0, 1.0]`
47pub fn sample<R: Rng + ?Sized>(n: usize, rng: &mut R, params: &SamplerParams) -> LatinSquare {
48    assert!((2..=255).contains(&n), "n must be in range 2..=255");
49    assert!(
50        (0.0..=1.0).contains(&params.p_do_nothing),
51        "p_do_nothing must be in [0.0, 1.0]"
52    );
53
54    let mut state = JMState::new_cyclic(n);
55
56    // burn_in: steps to reach equilibrium from initial cyclic state
57    // Default to n³ if not specified
58    let burn_in = params.burn_in.unwrap_or((n * n * n) as u64);
59    for _ in 0..burn_in {
60        step(&mut state, rng, params);
61    }
62
63    // Ensure we return a proper Latin square
64    while !state.is_proper() {
65        step(&mut state, rng, params);
66    }
67
68    state.to_latin_square()
69}
70
71/// An iterator that produces approximately uniform Latin squares.
72///
73/// Created by [`Sampler::new`]. Uses MCMC sampling with the Jacobson-Matthews
74/// algorithm for ergodicity. Burn-in is performed on the first call to `next()`.
75///
76/// # Example
77///
78/// ```
79/// use latin_sampler::{Sampler, SamplerParams};
80/// use rand_chacha::ChaCha20Rng;
81/// use rand::SeedableRng;
82///
83/// let rng = ChaCha20Rng::seed_from_u64(0);
84/// let params = SamplerParams::default();
85/// let sampler = Sampler::new(7, rng, params);
86///
87/// for sq in sampler.take(10) {
88///     println!("Cell (0,0) = {}", sq.get(0, 0));
89/// }
90/// ```
91pub struct Sampler<R> {
92    n: usize,
93    state: JMState,
94    rng: R,
95    params: SamplerParams,
96    burned_in: bool,
97}
98
99impl<R: Rng> Sampler<R> {
100    /// Create a new sampler for Latin squares of order `n`.
101    ///
102    /// The sampler starts from a cyclic Latin square and performs burn-in
103    /// on the first call to `next()`.
104    ///
105    /// # Panics
106    /// Panics if:
107    /// - `n < 2` or `n > 255`
108    /// - `p_do_nothing` is not in `[0.0, 1.0]`
109    pub fn new(n: usize, rng: R, params: SamplerParams) -> Self {
110        assert!((2..=255).contains(&n), "n must be in range 2..=255");
111        assert!(
112            (0.0..=1.0).contains(&params.p_do_nothing),
113            "p_do_nothing must be in [0.0, 1.0]"
114        );
115
116        Self {
117            n,
118            state: JMState::new_cyclic(n),
119            rng,
120            params,
121            burned_in: false,
122        }
123    }
124}
125
126impl<R: Rng> Iterator for Sampler<R> {
127    type Item = LatinSquare;
128
129    fn next(&mut self) -> Option<Self::Item> {
130        // First call: perform burn-in
131        if !self.burned_in {
132            let burn_in = self
133                .params
134                .burn_in
135                .unwrap_or((self.n * self.n * self.n) as u64);
136            for _ in 0..burn_in {
137                step(&mut self.state, &mut self.rng, &self.params);
138            }
139            self.burned_in = true;
140        } else {
141            // Subsequent calls: apply thinning steps
142            // Default thinning is 3×n² for approximate independence
143            let thinning = self.params.thinning.unwrap_or((3 * self.n * self.n) as u64);
144            for _ in 0..thinning {
145                step(&mut self.state, &mut self.rng, &self.params);
146            }
147        }
148
149        // Ensure we return a proper Latin square
150        while !self.state.is_proper() {
151            step(&mut self.state, &mut self.rng, &self.params);
152        }
153
154        Some(self.state.to_latin_square())
155    }
156}
157
158/// Performs a single MCMC step using Jacobson-Matthews.
159fn step<R: Rng + ?Sized>(state: &mut JMState, rng: &mut R, params: &SamplerParams) {
160    // With probability p_do_nothing: do nothing (for aperiodicity)
161    if rng.random::<f64>() < params.p_do_nothing {
162        return;
163    }
164
165    state.step(rng);
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use rand::SeedableRng;
172    use rand_chacha::ChaCha20Rng;
173
174    fn quick_params() -> SamplerParams {
175        SamplerParams {
176            burn_in: Some(1000),
177            thinning: Some(1),
178            p_do_nothing: 0.01,
179        }
180    }
181
182    #[test]
183    fn reproducibility_same_seed_same_output() {
184        let params = quick_params();
185
186        let mut rng1 = ChaCha20Rng::seed_from_u64(0);
187        let sq1 = sample(7, &mut rng1, &params);
188
189        let mut rng2 = ChaCha20Rng::seed_from_u64(0);
190        let sq2 = sample(7, &mut rng2, &params);
191
192        assert_eq!(sq1, sq2, "Same seed should produce identical squares");
193    }
194
195    #[test]
196    fn different_seed_different_output_smoke() {
197        let params = quick_params();
198
199        // Try a few different seed pairs
200        for offset in 0u64..5 {
201            let mut rng1 = ChaCha20Rng::seed_from_u64(offset);
202            let sq1 = sample(7, &mut rng1, &params);
203
204            let mut rng2 = ChaCha20Rng::seed_from_u64(offset + 100);
205            let sq2 = sample(7, &mut rng2, &params);
206
207            if sq1 != sq2 {
208                return; // Success: found different outputs
209            }
210        }
211        panic!("All tested seed pairs produced identical squares (extremely unlikely)");
212    }
213
214    #[test]
215    fn iterator_reproducibility() {
216        let params = quick_params();
217
218        // Create two samplers with the same seed
219        let rng1 = ChaCha20Rng::seed_from_u64(0);
220        let sampler1 = Sampler::new(5, rng1, params.clone());
221
222        let rng2 = ChaCha20Rng::seed_from_u64(0);
223        let sampler2 = Sampler::new(5, rng2, params);
224
225        // Take 10 samples from each
226        let squares1: Vec<_> = sampler1.take(10).collect();
227        let squares2: Vec<_> = sampler2.take(10).collect();
228
229        assert_eq!(
230            squares1, squares2,
231            "Same seed should produce identical sequence"
232        );
233    }
234
235    #[test]
236    fn iterator_thinning_spacing() {
237        // Create params with different thinning values
238        let params_thin1 = SamplerParams {
239            burn_in: Some(1000),
240            thinning: Some(1),
241            ..Default::default()
242        };
243        let params_thin100 = SamplerParams {
244            burn_in: Some(1000),
245            thinning: Some(100),
246            ..Default::default()
247        };
248
249        let rng1 = ChaCha20Rng::seed_from_u64(0);
250        let sampler1 = Sampler::new(5, rng1, params_thin1);
251
252        let rng2 = ChaCha20Rng::seed_from_u64(0);
253        let sampler2 = Sampler::new(5, rng2, params_thin100);
254
255        // Take 5 samples from each
256        let squares1: Vec<_> = sampler1.take(5).collect();
257        let squares2: Vec<_> = sampler2.take(5).collect();
258
259        // First sample should be the same (same burn-in)
260        assert_eq!(
261            squares1[0], squares2[0],
262            "First sample after burn-in should be identical"
263        );
264
265        // Subsequent samples should differ due to different thinning
266        let different_count = squares1
267            .iter()
268            .zip(squares2.iter())
269            .skip(1)
270            .filter(|(a, b)| a != b)
271            .count();
272
273        assert!(
274            different_count > 0,
275            "Different thinning should produce different sequences after first sample"
276        );
277    }
278}