Skip to main content

oxicuda_seq/mrf/
wolff.rs

1//! Wolff (1989) single-cluster Monte-Carlo update for the ferromagnetic Ising
2//! model.
3//!
4//! Where Swendsen-Wang (see [`super::swendsen_wang`]) partitions the *entire*
5//! lattice into clusters every sweep, Wolff grows and flips a **single** cluster
6//! seeded at a random site.  This is often more efficient per unit work near
7//! criticality because the algorithm preferentially builds large clusters (the
8//! probability of seeding inside a big cluster is proportional to its size),
9//! and a single flood-fill touches only the spins it actually flips.
10//!
11//! One update proceeds as:
12//!
13//! 1. **Seed.**  Pick a site `k` uniformly at random; remember its spin value
14//!    `s_seed`.
15//! 2. **Grow.**  Flood-fill (stack / BFS) from `k`.  For each aligned neighbour
16//!    `j` (`s_j == s_seed`) not yet in the cluster, add it with probability
17//!    `p = 1 − exp(−2 β J)`.
18//! 3. **Flip.**  Flip the whole grown cluster unconditionally.
19//!
20//! Because the seed cluster is *always* flipped (not with probability ½), the
21//! single-cluster move still satisfies detailed balance — the asymmetry in the
22//! proposal is exactly cancelled by the Fortuin-Kasteleyn bond weights.  The
23//! chain is ergodic: over enough updates it visits both `+M` and `−M` ordered
24//! states, decorrelating the global magnetisation far faster than single-spin
25//! Metropolis near `T_c`.
26//!
27//! Spins are `i8` values in `{−1, +1}`.  Square lattices (open or periodic) and
28//! general graphs are both supported; the geometry and validation helpers are
29//! shared with [`super::swendsen_wang`].
30
31use super::swendsen_wang::{Geometry, check_spins, magnetization, validate_graph, validate_square};
32use crate::error::{SeqError, SeqResult};
33use crate::handle::LcgRng;
34
35/// Configuration for a Wolff sampler.
36#[derive(Debug, Clone)]
37pub struct WolffConfig {
38    /// Ferromagnetic coupling constant `J`.
39    pub coupling: f64,
40    /// Geometry of the spin system.
41    pub(crate) geometry: Geometry,
42}
43
44impl WolffConfig {
45    /// Square lattice configuration.
46    pub fn square(rows: usize, cols: usize, coupling: f64, periodic: bool) -> SeqResult<Self> {
47        validate_square(rows, cols, coupling)?;
48        Ok(Self {
49            coupling,
50            geometry: Geometry::Square {
51                rows,
52                cols,
53                periodic,
54            },
55        })
56    }
57
58    /// General-graph configuration from an adjacency list.
59    pub fn graph(adjacency: Vec<Vec<usize>>, coupling: f64) -> SeqResult<Self> {
60        validate_graph(&adjacency, coupling)?;
61        Ok(Self {
62            coupling,
63            geometry: Geometry::Graph { adjacency },
64        })
65    }
66
67    /// Number of sites in the configured geometry.
68    pub fn n_sites(&self) -> usize {
69        self.geometry.n_sites()
70    }
71}
72
73/// Wolff single-cluster sampler.
74///
75/// Precomputes a per-site neighbour table from the geometry so the flood-fill
76/// can enumerate neighbours in `O(degree)` without re-deriving lattice indices.
77#[derive(Debug, Clone)]
78pub struct Wolff {
79    coupling: f64,
80    /// `neighbours[i]` = adjacency of site `i` (square lattice expanded once).
81    neighbours: Vec<Vec<usize>>,
82    n_sites: usize,
83    /// Size of the cluster flipped on the most recent step (diagnostic).
84    last_cluster_size: usize,
85}
86
87/// Expand a [`Geometry`] into an explicit per-site neighbour table.
88fn build_neighbours(geometry: &Geometry) -> Vec<Vec<usize>> {
89    match geometry {
90        Geometry::Square {
91            rows,
92            cols,
93            periodic,
94        } => {
95            let rows = *rows;
96            let cols = *cols;
97            let periodic = *periodic;
98            let mut nbrs = vec![Vec::new(); rows * cols];
99            for r in 0..rows {
100                for c in 0..cols {
101                    let i = r * cols + c;
102                    // Left / right.
103                    if c + 1 < cols {
104                        nbrs[i].push(r * cols + (c + 1));
105                        nbrs[r * cols + (c + 1)].push(i);
106                    } else if periodic && cols > 1 {
107                        let j = r * cols; // wrap to column 0
108                        nbrs[i].push(j);
109                        nbrs[j].push(i);
110                    }
111                    // Up / down.
112                    if r + 1 < rows {
113                        nbrs[i].push((r + 1) * cols + c);
114                        nbrs[(r + 1) * cols + c].push(i);
115                    } else if periodic && rows > 1 {
116                        let j = c; // wrap to row 0
117                        nbrs[i].push(j);
118                        nbrs[j].push(i);
119                    }
120                }
121            }
122            nbrs
123        }
124        Geometry::Graph { adjacency } => adjacency.clone(),
125    }
126}
127
128impl Wolff {
129    /// Build a sampler from a validated configuration.
130    pub fn new(cfg: WolffConfig) -> Self {
131        let neighbours = build_neighbours(&cfg.geometry);
132        let n_sites = cfg.geometry.n_sites();
133        Self {
134            coupling: cfg.coupling,
135            neighbours,
136            n_sites,
137            last_cluster_size: 0,
138        }
139    }
140
141    /// Convenience constructor for a square lattice.
142    pub fn square(rows: usize, cols: usize, coupling: f64, periodic: bool) -> SeqResult<Self> {
143        Ok(Self::new(WolffConfig::square(
144            rows, cols, coupling, periodic,
145        )?))
146    }
147
148    /// Convenience constructor for a general graph.
149    pub fn from_graph(adjacency: Vec<Vec<usize>>, coupling: f64) -> SeqResult<Self> {
150        Ok(Self::new(WolffConfig::graph(adjacency, coupling)?))
151    }
152
153    /// Number of sites in the lattice / graph.
154    pub fn n_sites(&self) -> usize {
155        self.n_sites
156    }
157
158    /// Size of the cluster flipped on the most recent [`Wolff::step`].
159    pub fn last_cluster_size(&self) -> usize {
160        self.last_cluster_size
161    }
162
163    /// Bond / addition probability `p = 1 − exp(−2 β J)`.  See
164    /// [`super::swendsen_wang::SwendsenWang::bond_probability`] for the
165    /// clamping rationale.
166    #[inline]
167    pub fn bond_probability(&self, beta: f64) -> f64 {
168        let two_beta_j = 2.0 * beta * self.coupling;
169        if two_beta_j <= 0.0 {
170            0.0
171        } else {
172            (1.0 - (-two_beta_j).exp()).clamp(0.0, 1.0)
173        }
174    }
175
176    /// Perform one Wolff single-cluster update in place.
177    ///
178    /// Seeds at a uniformly random site, grows the aligned cluster by stack
179    /// flood-fill (each aligned neighbour joins with probability
180    /// `p = 1 − exp(−2 β J)`), then flips the whole cluster.
181    ///
182    /// # Errors
183    /// [`SeqError::ShapeMismatch`] on a site-count mismatch,
184    /// [`SeqError::InvalidParameter`] for non-finite `beta` or non-`±1` spins.
185    pub fn step(&mut self, spins: &mut [i8], beta: f64, rng: &mut LcgRng) -> SeqResult<()> {
186        check_spins(spins, self.n_sites, beta)?;
187        if self.n_sites == 0 {
188            return Err(SeqError::EmptyInput);
189        }
190
191        let p = self.bond_probability(beta);
192        let seed = rng.next_usize(self.n_sites);
193        let s_seed = spins[seed];
194
195        // Flood-fill cluster growth using an explicit stack.  `in_cluster`
196        // marks membership; a site is added to the stack only once.
197        let mut in_cluster = vec![false; self.n_sites];
198        let mut stack: Vec<usize> = Vec::new();
199        in_cluster[seed] = true;
200        stack.push(seed);
201        let mut cluster: Vec<usize> = vec![seed];
202
203        while let Some(site) = stack.pop() {
204            for &nbr in &self.neighbours[site] {
205                if !in_cluster[nbr] && spins[nbr] == s_seed && rng.next_f64() < p {
206                    in_cluster[nbr] = true;
207                    stack.push(nbr);
208                    cluster.push(nbr);
209                }
210            }
211        }
212
213        // Flip the whole cluster unconditionally.
214        for &site in &cluster {
215            spins[site] = -spins[site];
216        }
217        self.last_cluster_size = cluster.len();
218        Ok(())
219    }
220
221    /// Mean magnetisation `(1/N) Σ s_i ∈ [−1, +1]`.
222    ///
223    /// # Errors
224    /// [`SeqError::ShapeMismatch`] on a site-count mismatch.
225    pub fn magnetization(&self, spins: &[i8]) -> SeqResult<f64> {
226        magnetization(spins, self.n_sites)
227    }
228
229    /// Total Ising energy `E = −J Σ_(i,j) s_i s_j` over the geometry's edges
230    /// (no external field).  Each undirected edge is counted once.
231    ///
232    /// # Errors
233    /// [`SeqError::ShapeMismatch`] on a site-count mismatch.
234    pub fn energy(&self, spins: &[i8]) -> SeqResult<f64> {
235        if spins.len() != self.n_sites {
236            return Err(SeqError::ShapeMismatch {
237                expected: self.n_sites,
238                got: spins.len(),
239            });
240        }
241        let mut e = 0.0;
242        // Sum over canonical edges (i < j) so every bond is counted exactly once.
243        for (i, nbrs) in self.neighbours.iter().enumerate() {
244            for &j in nbrs {
245                if i < j {
246                    e -= self.coupling * (spins[i] as f64) * (spins[j] as f64);
247                }
248            }
249        }
250        Ok(e)
251    }
252
253    /// Borrow the per-site neighbour table.
254    #[cfg(test)]
255    pub(crate) fn neighbours(&self) -> &[Vec<usize>] {
256        &self.neighbours
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    const BETA_ORDERED: f64 = 0.70;
265    const BETA_DISORDERED: f64 = 0.20;
266
267    fn run_chain(
268        w: &mut Wolff,
269        spins: &mut [i8],
270        beta: f64,
271        seed: u64,
272        burn: usize,
273        samples: usize,
274    ) -> f64 {
275        let mut rng = LcgRng::new(seed);
276        for _ in 0..burn {
277            w.step(spins, beta, &mut rng).expect("step");
278        }
279        let mut acc = 0.0;
280        for _ in 0..samples {
281            w.step(spins, beta, &mut rng).expect("step");
282            acc += w.magnetization(spins).expect("mag").abs();
283        }
284        acc / samples as f64
285    }
286
287    #[test]
288    fn neighbour_table_square_periodic_degree_four() {
289        let w = Wolff::square(5, 5, 1.0, true).expect("ok");
290        // Every site on a periodic lattice has exactly 4 neighbours.
291        for nbrs in w.neighbours() {
292            assert_eq!(nbrs.len(), 4);
293        }
294        assert_eq!(w.n_sites(), 25);
295    }
296
297    #[test]
298    fn neighbour_table_square_open_corner_and_centre() {
299        let w = Wolff::square(3, 3, 1.0, false).expect("ok");
300        // Corner site 0 has 2 neighbours; centre site 4 has 4.
301        assert_eq!(w.neighbours()[0].len(), 2);
302        assert_eq!(w.neighbours()[4].len(), 4);
303    }
304
305    #[test]
306    fn bond_probability_formula() {
307        let w = Wolff::square(2, 2, 1.0, false).expect("ok");
308        let beta = 0.33_f64;
309        let expected = 1.0 - (-2.0 * beta).exp();
310        assert!((w.bond_probability(beta) - expected).abs() < 1e-12);
311        assert_eq!(w.bond_probability(0.0), 0.0);
312    }
313
314    #[test]
315    fn cluster_contains_only_aligned_spins() {
316        // Build a checkerboard: no two neighbours are aligned, so any seeded
317        // cluster is exactly the singleton seed — and flipping a single spin
318        // changes |M| by 2/N.
319        let w_dims = (6usize, 6usize);
320        let mut w = Wolff::square(w_dims.0, w_dims.1, 1.0, true).expect("ok");
321        let mut spins: Vec<i8> = (0..36)
322            .map(|i| if (i / 6 + i % 6) % 2 == 0 { 1 } else { -1 })
323            .collect();
324        let mut rng = LcgRng::new(1);
325        w.step(&mut spins, 5.0, &mut rng).expect("step");
326        // On a checkerboard the neighbours are anti-aligned, so the seed cluster
327        // is a single site regardless of β.
328        assert_eq!(
329            w.last_cluster_size(),
330            1,
331            "checkerboard cluster is singleton"
332        );
333    }
334
335    #[test]
336    fn high_beta_uniform_flips_whole_lattice() {
337        // From a uniform state at high β the cluster spans the connected
338        // lattice and the global flip yields the opposite uniform state.
339        let mut w = Wolff::square(8, 8, 1.0, true).expect("ok");
340        let mut spins = vec![1i8; 64];
341        let mut rng = LcgRng::new(42);
342        w.step(&mut spins, 5.0, &mut rng).expect("step");
343        assert_eq!(w.last_cluster_size(), 64, "should engulf whole lattice");
344        assert!(spins.iter().all(|&s| s == -1), "uniform −1 after flip");
345    }
346
347    #[test]
348    fn phase_transition_ordered_vs_disordered() {
349        let mut w = Wolff::square(16, 16, 1.0, true).expect("ok");
350        let mut spins = vec![1i8; 256];
351        let m_ordered = run_chain(&mut w, &mut spins, BETA_ORDERED, 17, 60, 120);
352
353        let mut spins2 = vec![1i8; 256];
354        let m_dis = run_chain(&mut w, &mut spins2, BETA_DISORDERED, 17, 60, 120);
355
356        assert!(
357            m_ordered > 0.8,
358            "ordered |M|={m_ordered} should be high (>0.8)"
359        );
360        assert!(m_dis < 0.4, "disordered |M|={m_dis} should be low (<0.4)");
361        assert!(
362            m_ordered - m_dis > 0.4,
363            "ordered ({m_ordered}) vs disordered ({m_dis}) must separate"
364        );
365    }
366
367    #[test]
368    fn energy_change_matches_boundary_sum() {
369        // A whole-cluster flip changes energy only on the cluster boundary.
370        // ΔE = 2 J Σ_{(i in C, j not in C)} s_i s_j (pre-flip spins).
371        let mut w = Wolff::square(7, 7, 1.0, true).expect("ok");
372        let mut spins: Vec<i8> = (0..49)
373            .map(|i| if (i * 7 + 3) % 5 < 2 { 1 } else { -1 })
374            .collect();
375
376        // Reproduce the exact cluster the next step will build by replaying the
377        // RNG, so we can compute the analytic boundary energy change.
378        let beta = 0.45;
379        let p = w.bond_probability(beta);
380        let mut rng = LcgRng::new(123);
381        let seed = rng.next_usize(w.n_sites());
382        let s_seed = spins[seed];
383        let mut in_cluster = vec![false; w.n_sites()];
384        let mut stack = vec![seed];
385        in_cluster[seed] = true;
386        while let Some(site) = stack.pop() {
387            for &nbr in &w.neighbours()[site] {
388                if !in_cluster[nbr] && spins[nbr] == s_seed && rng.next_f64() < p {
389                    in_cluster[nbr] = true;
390                    stack.push(nbr);
391                }
392            }
393        }
394        // Analytic boundary energy change (pre-flip spins).
395        let mut delta = 0.0;
396        for (i, nbrs) in w.neighbours().iter().enumerate() {
397            for &j in nbrs {
398                if i < j && in_cluster[i] != in_cluster[j] {
399                    delta += 2.0 * 1.0 * (spins[i] as f64) * (spins[j] as f64);
400                }
401            }
402        }
403
404        let e_before = w.energy(&spins).expect("e");
405        // Now actually run the step with a *fresh* RNG at the same seed so the
406        // same cluster is grown and flipped.
407        let mut rng2 = LcgRng::new(123);
408        w.step(&mut spins, beta, &mut rng2).expect("step");
409        let e_after = w.energy(&spins).expect("e");
410        assert!(
411            (e_after - e_before - delta).abs() < 1e-9,
412            "ΔE={} should equal boundary sum {}",
413            e_after - e_before,
414            delta
415        );
416    }
417
418    #[test]
419    fn determinism_fixed_seed() {
420        let mut a = Wolff::square(10, 10, 1.0, true).expect("ok");
421        let mut b = Wolff::square(10, 10, 1.0, true).expect("ok");
422        let mut sa = vec![1i8; 100];
423        let mut sb = vec![1i8; 100];
424        let mut ra = LcgRng::new(7777);
425        let mut rb = LcgRng::new(7777);
426        for _ in 0..50 {
427            a.step(&mut sa, 0.45, &mut ra).expect("step");
428            b.step(&mut sb, 0.45, &mut rb).expect("step");
429        }
430        assert_eq!(sa, sb);
431        assert_eq!(a.last_cluster_size(), b.last_cluster_size());
432    }
433
434    #[test]
435    fn ergodic_global_sign_flips() {
436        // Single-cluster updates flip the global magnetisation sign readily at
437        // high β — the decorrelation property single-spin Metropolis lacks.
438        let mut w = Wolff::square(12, 12, 1.0, true).expect("ok");
439        let mut spins = vec![1i8; 144];
440        let mut rng = LcgRng::new(2025);
441        let mut saw_positive = false;
442        let mut saw_negative = false;
443        for _ in 0..150 {
444            w.step(&mut spins, 0.60, &mut rng).expect("step");
445            let m = w.magnetization(&spins).expect("m");
446            if m > 0.5 {
447                saw_positive = true;
448            }
449            if m < -0.5 {
450                saw_negative = true;
451            }
452        }
453        assert!(
454            saw_positive && saw_negative,
455            "must explore both ±M states (+ = {saw_positive}, − = {saw_negative})"
456        );
457    }
458
459    #[test]
460    fn graph_variant_path_three() {
461        // Path graph 0—1—2; at high β a seed at an end can engulf the path.
462        let adjacency = vec![vec![1], vec![0, 2], vec![1]];
463        let mut w = Wolff::from_graph(adjacency, 1.0).expect("ok");
464        assert_eq!(w.n_sites(), 3);
465        let mut spins = vec![1i8; 3];
466        let mut rng = LcgRng::new(9);
467        // Repeatedly stepping should at some point flip a multi-site cluster.
468        let mut saw_multi = false;
469        for _ in 0..30 {
470            w.step(&mut spins, 4.0, &mut rng).expect("step");
471            if w.last_cluster_size() > 1 {
472                saw_multi = true;
473            }
474        }
475        assert!(saw_multi, "high-β path should grow a multi-site cluster");
476    }
477
478    #[test]
479    fn err_dimension_mismatch() {
480        let mut w = Wolff::square(4, 4, 1.0, false).expect("ok");
481        let mut spins = vec![1i8; 17]; // should be 16
482        let mut rng = LcgRng::new(1);
483        match w.step(&mut spins, 0.4, &mut rng) {
484            Err(SeqError::ShapeMismatch { expected, got }) => {
485                assert_eq!(expected, 16);
486                assert_eq!(got, 17);
487            }
488            other => panic!("expected ShapeMismatch, got {other:?}"),
489        }
490    }
491
492    #[test]
493    fn err_bad_lattice_and_invalid_spin() {
494        assert!(matches!(
495            Wolff::square(3, 0, 1.0, false),
496            Err(SeqError::InvalidConfiguration(_))
497        ));
498        let mut w = Wolff::square(2, 2, 1.0, false).expect("ok");
499        let mut spins = vec![1i8, -1, 0, 1]; // 0 is not ±1
500        let mut rng = LcgRng::new(1);
501        assert!(matches!(
502            w.step(&mut spins, 0.4, &mut rng),
503            Err(SeqError::InvalidParameter { .. })
504        ));
505        // Infinite beta.
506        let mut good = vec![1i8; 4];
507        assert!(matches!(
508            w.step(&mut good, f64::INFINITY, &mut rng),
509            Err(SeqError::InvalidParameter { .. })
510        ));
511    }
512
513    #[test]
514    fn err_graph_invariants() {
515        assert!(matches!(
516            Wolff::from_graph(vec![vec![1], vec![]], 1.0),
517            Err(SeqError::GraphInvariantViolated(_))
518        ));
519        assert!(matches!(
520            Wolff::from_graph(vec![], 1.0),
521            Err(SeqError::EmptyInput)
522        ));
523        assert!(matches!(
524            Wolff::from_graph(vec![vec![5], vec![]], 1.0),
525            Err(SeqError::IndexOutOfBounds { .. })
526        ));
527    }
528}