Skip to main content

spin_sim/simulation/
mod.rs

1pub(crate) mod driver;
2pub mod realization;
3pub mod xy;
4
5pub use realization::Realization;
6
7use std::sync::atomic::AtomicBool;
8
9use crate::config::{ClusterAction, OverlapClusterBuildMode, SimConfig, SweepMode};
10use crate::geometry::Lattice;
11use crate::statistics::graph::{fold_histograms, histograms, ClusterObservationAccums};
12use crate::statistics::physics::{CachedTotals, PhysicsCollector, PhysicsOptions};
13use crate::statistics::results::{
14    add_move_counts, add_rmc_counts, fold_counts, OverlapMoveCounts, RmcEdgeCounts,
15};
16use crate::statistics::{
17    AutocorrAccum, ClusterSnapshot, ClusterStats, Diagnostics, DisorderSample,
18    EquilDiagnosticAccum, OverlapAccum, Statistics, SweepResult,
19};
20use crate::{clusters, mcmc, spins};
21
22/// Energies and magnetization sums of every system, kept in step with the spins.
23///
24/// With unit couplings a sweep reports exact integer deltas, so the cache survives
25/// single-spin sweeps; every other mutation invalidates it until the next refresh.
26struct ObservableCache<'a> {
27    lattice: &'a Lattice,
28    lookup: Option<&'a mcmc::sweep::UnitCouplingLookup>,
29    sequential: bool,
30    /// Exact -H per system, maintained only with a unit-coupling lookup.
31    unit_totals: Vec<i64>,
32    deltas: Vec<mcmc::sweep::UnitSweepDelta>,
33    magnetization_sums: Vec<i64>,
34    valid: bool,
35}
36
37impl<'a> ObservableCache<'a> {
38    fn new(
39        lattice: &'a Lattice,
40        lookup: Option<&'a mcmc::sweep::UnitCouplingLookup>,
41        n_systems: usize,
42        config: &SimConfig,
43    ) -> Self {
44        let n_tracked = if lookup.is_some() { n_systems } else { 0 };
45        Self {
46            lattice,
47            lookup,
48            sequential: config.sequential,
49            unit_totals: vec![0; n_tracked],
50            deltas: vec![mcmc::sweep::UnitSweepDelta::default(); n_tracked],
51            magnetization_sums: vec![0; n_systems],
52            valid: false,
53        }
54    }
55
56    /// Where the coming sweep reports its deltas. They are tracked only while the
57    /// cache is valid and `consumed` before a cluster move invalidates it anyway.
58    fn begin_sweep(&mut self, consumed: bool) -> Option<&mut [mcmc::sweep::UnitSweepDelta]> {
59        self.valid &= self.lookup.is_some() && consumed;
60        self.valid.then_some(self.deltas.as_mut_slice())
61    }
62
63    /// Applies the deltas of a tracked sweep.
64    fn end_sweep(&mut self, energies: &mut [f32]) {
65        if !self.valid {
66            return;
67        }
68        for (system, delta) in self.deltas.iter().enumerate() {
69            self.unit_totals[system] += delta.interaction;
70            self.magnetization_sums[system] += delta.magnetization;
71            energies[system] =
72                spins::energy::unit_energy(self.unit_totals[system], self.lattice.n_spins);
73        }
74    }
75
76    fn invalidate(&mut self) {
77        self.valid = false;
78    }
79
80    /// Recomputes energies and magnetization sums unless they are current.
81    fn ensure(&mut self, real: &mut Realization) {
82        if self.valid {
83            return;
84        }
85        self.valid = true;
86        let Some(lookup) = self.lookup else {
87            spins::energy::refresh_energies_and_magnetizations(
88                self.lattice,
89                &real.spins,
90                &real.couplings,
91                &mut real.energies,
92                &mut self.magnetization_sums,
93                self.sequential,
94            );
95            return;
96        };
97        spins::energy::refresh_unit_totals(
98            self.lattice,
99            &real.spins,
100            lookup.couplings(),
101            &mut self.unit_totals,
102            &mut self.magnetization_sums,
103            self.sequential,
104        );
105        for (energy, &total) in real.energies.iter_mut().zip(&self.unit_totals) {
106            *energy = spins::energy::unit_energy(total, self.lattice.n_spins);
107        }
108    }
109}
110
111/// Run the full Monte Carlo loop (warmup + measurement) for one [`Realization`].
112///
113/// Each sweep consists of:
114/// 1. A full single-spin pass (`sweep_mode`: Metropolis or Gibbs)
115/// 2. An optional cluster update (every `cluster_update.interval` sweeps)
116/// 3. Measurement (after `warmup_sweeps`)
117/// 4. Optional overlap cluster move (every `overlap_cluster.interval` sweeps; `n_replicas` must reach the largest mode's group size)
118/// 5. Optional parallel tempering (every `pt_interval` sweeps)
119///
120/// `on_sweep` is called once per sweep (useful for progress bars).
121#[allow(clippy::too_many_arguments)]
122pub fn run_sweep_loop(
123    lattice: &Lattice,
124    real: &mut Realization,
125    n_replicas: usize,
126    n_temps: usize,
127    config: &SimConfig,
128    interrupted: &AtomicBool,
129    on_sweep: &(dyn Fn() + Sync),
130    realization_idx: usize,
131) -> Result<SweepResult, String> {
132    driver::validate_layout(config, n_replicas, n_temps)?;
133    real.validate(lattice, n_replicas, n_temps)?;
134    run_sweep_loop_impl(
135        lattice,
136        real,
137        n_replicas,
138        n_temps,
139        config,
140        interrupted,
141        on_sweep,
142        realization_idx,
143        true,
144        None,
145    )
146}
147
148#[allow(clippy::too_many_arguments)]
149fn run_sweep_loop_impl(
150    lattice: &Lattice,
151    real: &mut Realization,
152    n_replicas: usize,
153    n_temps: usize,
154    config: &SimConfig,
155    interrupted: &AtomicBool,
156    on_sweep: &(dyn Fn() + Sync),
157    realization_idx: usize,
158    materialize_disabled_stats: bool,
159    physics_options: Option<&PhysicsOptions>,
160) -> Result<SweepResult, String> {
161    let acceptance = match config.sweep_mode {
162        SweepMode::Metropolis => Some(mcmc::sweep::Acceptance::Metropolis),
163        SweepMode::Gibbs => Some(mcmc::sweep::Acceptance::Gibbs),
164        SweepMode::None => None,
165    };
166    let unit_lookup = acceptance.and_then(|acceptance| {
167        mcmc::sweep::UnitCouplingLookup::new(
168            &real.couplings,
169            &real.temperatures,
170            lattice.n_neighbors,
171            acceptance,
172        )
173    });
174
175    let n_spins = lattice.n_spins;
176    let n_systems = n_replicas * n_temps;
177    let n_sweeps = config.n_sweeps;
178
179    let n_modes = config.overlap_cluster.as_ref().map_or(0, |h| h.modes.len());
180
181    let n_pairs = n_replicas / 2;
182    let overlap_active: Vec<bool> = real.temperatures[..n_temps]
183        .iter()
184        .map(|&t| {
185            config
186                .overlap_cluster
187                .as_ref()
188                .and_then(|oc| oc.max_temperature)
189                .is_none_or(|max| t <= max)
190        })
191        .collect();
192    let observe_fk = config
193        .cluster_update
194        .as_ref()
195        .is_some_and(|cluster| cluster.action == ClusterAction::Observe);
196    let observe_overlap = config
197        .overlap_cluster
198        .as_ref()
199        .is_some_and(|cluster| cluster.action == ClusterAction::Observe);
200    let collect_fk = config
201        .cluster_update
202        .as_ref()
203        .is_some_and(|c| c.collect_stats || c.action == ClusterAction::Observe);
204    let collect_overlap = config
205        .overlap_cluster
206        .as_ref()
207        .is_some_and(|h| h.collect_stats || h.action == ClusterAction::Observe)
208        && n_pairs > 0;
209
210    let mut observation_accums = ClusterObservationAccums::default();
211    if observe_fk {
212        observation_accums.enable_fk(n_temps);
213    }
214    if let Some(overlap) = config.overlap_cluster.as_ref().filter(|_| observe_overlap) {
215        for mode in &overlap.modes {
216            observation_accums.enable_overlap(mode, n_temps);
217        }
218    }
219
220    // The moves add to these histograms in place, one per output slot; they are
221    // folded per temperature once the run ends.
222    let mut fk_csd_slots = histograms(if collect_fk { n_systems } else { 0 }, n_spins);
223    // Spins visited by recorded FK updates, per temperature slot.
224    let fk_updates_spins = config
225        .cluster_update
226        .as_ref()
227        .is_some_and(|cluster| cluster.action == ClusterAction::Update);
228    let mut fk_visits = vec![0u64; if fk_updates_spins { n_systems } else { 0 }];
229    let mut fk_recorded_updates = 0u64;
230    let mut fk_observation_buf = if observe_fk {
231        vec![clusters::GraphObservationSlot::default(); n_systems]
232    } else {
233        vec![]
234    };
235
236    let mut overlap_csd_slots: Vec<Vec<Vec<u64>>> = (0..n_modes)
237        .map(|_| {
238            histograms(
239                if collect_overlap {
240                    n_temps * n_pairs
241                } else {
242                    0
243                },
244                n_spins,
245            )
246        })
247        .collect();
248
249    let collect_top = collect_overlap;
250    // Move counters are kept with the other overlap statistics, per output slot.
251    let collect_moves = config
252        .overlap_cluster
253        .as_ref()
254        .is_some_and(|overlap| overlap.collect_stats && overlap.action == ClusterAction::Update);
255    let mut move_slots: Vec<Vec<OverlapMoveCounts>> = (0..n_modes)
256        .map(|_| {
257            vec![OverlapMoveCounts::default(); if collect_moves { n_temps * n_pairs } else { 0 }]
258        })
259        .collect();
260    let mut rmc_slots = vec![RmcEdgeCounts::default(); if collect_moves { n_systems } else { 0 }];
261
262    let mut top4_accum: Vec<Vec<[f64; 4]>> = if collect_top {
263        (0..n_modes).map(|_| vec![[0.0; 4]; n_temps]).collect()
264    } else {
265        vec![]
266    };
267    let mut top4_n: Vec<usize> = if collect_top {
268        vec![0; n_modes]
269    } else {
270        vec![]
271    };
272    let mut top4_buf: Vec<[u32; 4]> = if collect_top {
273        vec![[0u32; 4]; n_temps * n_pairs]
274    } else {
275        vec![]
276    };
277    let mut overlap_observation_buf = if observe_overlap {
278        vec![clusters::GraphObservationSlot::default(); n_temps * n_pairs]
279    } else {
280        vec![]
281    };
282
283    // A snapshot waits for the next overlap call that publishes a graph.
284    let mut snapshot_due = false;
285
286    let snapshot_interval = if realization_idx == 0 {
287        config
288            .overlap_cluster
289            .as_ref()
290            .and_then(|oc| oc.snapshot_interval)
291    } else {
292        None
293    };
294    let n_pair_slots = n_temps * n_pairs;
295    let mut snap_buf: Vec<Vec<u32>> = if snapshot_interval.is_some() {
296        (0..n_pair_slots)
297            .map(|_| Vec::with_capacity(n_spins))
298            .collect()
299    } else {
300        vec![]
301    };
302    let mut blue_snap_buf: Vec<Vec<u32>> = if snapshot_interval.is_some() {
303        (0..n_pair_slots)
304            .map(|_| Vec::with_capacity(n_spins))
305            .collect()
306    } else {
307        vec![]
308    };
309    let mut spin_snap_buf: Vec<Vec<[Vec<i8>; 2]>> = if snapshot_interval.is_some() {
310        (0..n_pair_slots).map(|_| Vec::new()).collect()
311    } else {
312        vec![]
313    };
314    let mut sid_snap_buf: Vec<Vec<[usize; 2]>> = if snapshot_interval.is_some() {
315        (0..n_pair_slots).map(|_| Vec::new()).collect()
316    } else {
317        vec![]
318    };
319    let mut cluster_snapshots: Vec<ClusterSnapshot> = Vec::new();
320
321    let mut mags_stat = Statistics::new(n_temps, 1);
322    let mut mags2_stat = Statistics::new(n_temps, 1);
323    let mut mags4_stat = Statistics::new(n_temps, 1);
324    let mut energies_stat = Statistics::new(n_temps, 1);
325    let mut energies2_stat = Statistics::new(n_temps, 2);
326    let n_measurement_sweeps = config.n_measurement_sweeps();
327    let ac_max_lag = config.autocorrelation_lag();
328    let autocorrelation = || {
329        ac_max_lag.map(|k| {
330            AutocorrAccum::with_backend(
331                k,
332                n_temps,
333                config.autocorrelation_backend,
334                n_measurement_sweeps,
335            )
336        })
337    };
338    let mut m2_accum = autocorrelation();
339    let mut energy_accum = autocorrelation();
340    let mut q2_accum = autocorrelation().filter(|_| n_pairs > 0);
341    let collect_ac = ac_max_lag.is_some();
342    let collect_q2_ac = q2_accum.is_some();
343    // Replica-averaged m² and energy of the sweep being measured.
344    let mut m2_ac_buf = vec![0.0f64; if collect_ac { n_temps } else { 0 }];
345    let mut energy_ac_buf = m2_ac_buf.clone();
346
347    let equil_diag = config.equilibration_diagnostic;
348    let mut equil_accum = if equil_diag {
349        Some(EquilDiagnosticAccum::new(n_temps, n_sweeps))
350    } else {
351        None
352    };
353    let mut diag_e_buf = if equil_diag {
354        vec![0.0f32; n_temps]
355    } else {
356        vec![]
357    };
358
359    let mut ov_accum = OverlapAccum::new(
360        n_temps,
361        n_spins,
362        n_pairs,
363        lattice.n_neighbors,
364        equil_diag,
365        collect_q2_ac,
366    )
367    .with_replicas(n_replicas);
368    if !config.conditional_link_overlap {
369        ov_accum = ov_accum.without_conditional_link_overlap();
370    }
371
372    let mut mags_buf = vec![0.0f32; n_temps];
373    let mut mags2_buf = vec![0.0f32; n_temps];
374    let mut mags4_buf = vec![0.0f32; n_temps];
375    let mut energies_buf = vec![0.0f32; n_temps];
376    let mut cache = ObservableCache::new(lattice, unit_lookup.as_ref(), n_systems, config);
377
378    let mut physics = physics_options
379        .map(|options| PhysicsCollector::new(lattice, n_temps, options, 1))
380        .transpose()?
381        .map(|collector| {
382            if n_pairs > 0 {
383                collector.with_overlap(lattice)
384            } else {
385                collector
386            }
387        });
388    let occupied = if physics.is_some() {
389        vec![true; n_spins]
390    } else {
391        vec![]
392    };
393
394    let first_sweep = real.schedule.sweeps;
395    driver::run_sweeps(config, interrupted, on_sweep, first_sweep, |step| {
396        let sweep_id = step.index;
397        let record = step.record;
398        snapshot_due |= record && snapshot_interval.is_some_and(|si| step.clock.is_multiple_of(si));
399        if let Some(acceptance) = acceptance {
400            // Track only if the deltas can be consumed before a cluster move
401            // invalidates them anyway.
402            let cluster_mutates = step.cluster
403                && config
404                    .cluster_update
405                    .as_ref()
406                    .is_some_and(|cluster| cluster.action == ClusterAction::Update);
407            let overlap_mutates_first = !(record || equil_diag)
408                && config
409                    .overlap_cluster
410                    .as_ref()
411                    .is_some_and(|overlap| overlap.action == ClusterAction::Update && step.overlap);
412            let deltas = cache.begin_sweep(!cluster_mutates && !overlap_mutates_first);
413            mcmc::sweep::single_spin_sweep(
414                lattice,
415                &mut real.spins,
416                &real.couplings,
417                &real.temperatures,
418                &real.system_ids,
419                &mut real.rngs,
420                config.sequential,
421                acceptance,
422                unit_lookup.as_ref(),
423                deltas,
424            );
425            cache.end_sweep(&mut real.energies);
426        }
427
428        // An observation changes nothing, so one that is not recorded is skipped.
429        let cluster_cfg = config
430            .cluster_update
431            .as_ref()
432            .filter(|cluster| step.cluster && (cluster.action == ClusterAction::Update || record));
433        if let Some(cluster_cfg) = cluster_cfg {
434            let wolff = cluster_cfg.mode == crate::config::ClusterMode::Wolff;
435            let csd_out = (collect_fk && record).then_some(fk_csd_slots.as_mut_slice());
436            let observation_out = if cluster_cfg.action == ClusterAction::Observe && record {
437                fk_observation_buf.fill(clusters::GraphObservationSlot::default());
438                Some(fk_observation_buf.as_mut_slice())
439            } else {
440                None
441            };
442
443            clusters::fk_update(
444                lattice,
445                &mut real.spins,
446                &real.couplings,
447                &real.temperatures,
448                &real.system_ids,
449                &mut real.rngs,
450                wolff,
451                cluster_cfg.action,
452                csd_out,
453                observation_out,
454                config.sequential,
455                (fk_updates_spins && record).then_some(fk_visits.as_mut_slice()),
456            );
457            if cluster_cfg.action == ClusterAction::Update {
458                cache.invalidate();
459                fk_recorded_updates += u64::from(record);
460            }
461
462            if let Some(accum) = observation_accums.fk.as_mut().filter(|_| record) {
463                for (slot, observation) in fk_observation_buf.iter().copied().enumerate() {
464                    accum.push(slot % n_temps, observation);
465                }
466            }
467        }
468
469        // A single temperature has no edge to swap, so skip the energy refresh too.
470        let pt_this_sweep = step.temper && n_temps > 1;
471
472        // Tempering refreshes for itself below, after any overlap move.
473        if record || equil_diag {
474            cache.ensure(real);
475        }
476
477        if equil_diag {
478            diag_e_buf.fill(0.0);
479            #[allow(clippy::needless_range_loop)]
480            for r in 0..n_replicas {
481                let offset = r * n_temps;
482                for t in 0..n_temps {
483                    let system_id = real.system_ids[offset + t];
484                    diag_e_buf[t] += real.energies[system_id];
485                }
486            }
487            let inv = 1.0 / n_replicas as f32;
488            for v in diag_e_buf.iter_mut() {
489                *v *= inv;
490            }
491        }
492
493        if (equil_diag || record) && n_pairs > 0 {
494            ov_accum.collect(lattice, &real.spins, &real.system_ids, record);
495        }
496
497        if equil_diag {
498            equil_accum.as_mut().unwrap().push(
499                &diag_e_buf,
500                &ov_accum.diag_ql_buf,
501                &ov_accum.diag_q2_buf,
502            );
503        }
504
505        if record {
506            for t in 0..n_temps {
507                mags_buf[t] = 0.0;
508                mags2_buf[t] = 0.0;
509                mags4_buf[t] = 0.0;
510                energies_buf[t] = 0.0;
511            }
512
513            if collect_ac {
514                m2_ac_buf.fill(0.0);
515                energy_ac_buf.fill(0.0);
516            }
517
518            for r in 0..n_replicas {
519                let offset = r * n_temps;
520                for t in 0..n_temps {
521                    let system_id = real.system_ids[offset + t];
522                    let mag = cache.magnetization_sums[system_id] as f32 / n_spins as f32;
523                    let m2 = mag * mag;
524                    mags_buf[t] = mag;
525                    mags2_buf[t] = m2;
526                    mags4_buf[t] = m2 * m2;
527                    energies_buf[t] = real.energies[system_id];
528                }
529
530                if collect_ac {
531                    for t in 0..n_temps {
532                        m2_ac_buf[t] += mags2_buf[t] as f64;
533                        energy_ac_buf[t] += energies_buf[t] as f64;
534                    }
535                }
536
537                mags_stat.update(&mags_buf);
538                mags2_stat.update(&mags2_buf);
539                mags4_stat.update(&mags4_buf);
540                energies_stat.update(&energies_buf);
541                energies2_stat.update(&energies_buf);
542            }
543
544            if let (Some(m2), Some(energy)) = (m2_accum.as_mut(), energy_accum.as_mut()) {
545                let inv = 1.0 / n_replicas as f64;
546                for v in m2_ac_buf.iter_mut().chain(energy_ac_buf.iter_mut()) {
547                    *v *= inv;
548                }
549                m2.push(&m2_ac_buf);
550                energy.push(&energy_ac_buf);
551            }
552
553            if let Some(ref mut acc) = q2_accum {
554                acc.push(&ov_accum.q2_ac_buf);
555            }
556        }
557
558        if let Some(collector) = physics.as_mut().filter(|_| record) {
559            // Reuse this sweep's energy/magnetization pass; `energies` hold -H/N.
560            for (slot, &system) in real.system_ids.iter().enumerate() {
561                collector.measure_cached(
562                    lattice,
563                    &real.spins[system * n_spins..(system + 1) * n_spins],
564                    &real.couplings,
565                    &occupied,
566                    slot % n_temps,
567                    CachedTotals {
568                        energy: -(real.energies[system] as f64),
569                        magnetization: [cache.magnetization_sums[system] as f64, 0.0],
570                    },
571                );
572            }
573            // Same pairs (2p, 2p + 1) as the legacy overlap statistics.
574            let weight = n_replicas as f64 / n_pairs.max(1) as f64;
575            for pair in 0..n_pairs {
576                for t in 0..n_temps {
577                    let a = real.system_ids[2 * pair * n_temps + t];
578                    let b = real.system_ids[(2 * pair + 1) * n_temps + t];
579                    collector.measure_overlap(
580                        lattice,
581                        &real.spins[a * n_spins..(a + 1) * n_spins],
582                        &real.spins[b * n_spins..(b + 1) * n_spins],
583                        &occupied,
584                        t,
585                        weight,
586                    );
587                }
588            }
589            if n_pairs > 0 {
590                for t in 0..n_temps {
591                    let replicas: Vec<&[i8]> = (0..n_replicas)
592                        .map(|r| {
593                            let system = real.system_ids[r * n_temps + t];
594                            &real.spins[system * n_spins..(system + 1) * n_spins]
595                        })
596                        .collect();
597                    collector.measure_overlap_all_pairs(&replicas, t, n_replicas as f64);
598                }
599            }
600            collector.end_sweep(n_replicas);
601        }
602
603        if let Some(oc_cfg) = config.overlap_cluster.as_ref().filter(|_| step.overlap) {
604            let mode_idx = real.schedule.overlap_calls % n_modes;
605            let mode = &oc_cfg.modes[mode_idx];
606            // A skipped observation still counts, which keeps the mode rotation fixed.
607            real.schedule.overlap_calls += 1;
608            if oc_cfg.action == ClusterAction::Update || record {
609                if oc_cfg.action == ClusterAction::Update {
610                    cache.invalidate();
611                }
612
613                let ov_csd_out = (collect_overlap && record)
614                    .then_some(overlap_csd_slots[mode_idx].as_mut_slice());
615                let observation_out = if oc_cfg.action == ClusterAction::Observe && record {
616                    overlap_observation_buf.fill(clusters::GraphObservationSlot::default());
617                    Some(overlap_observation_buf.as_mut_slice())
618                } else {
619                    None
620                };
621
622                let top4_out = if collect_top && record {
623                    for slot in top4_buf.iter_mut() {
624                        *slot = [0u32; 4];
625                    }
626                    Some(top4_buf.as_mut_slice())
627                } else {
628                    None
629                };
630
631                let is_rmc = *mode == OverlapClusterBuildMode::Rmc;
632                // Replica Monte Carlo builds no overlap graph to publish.
633                let take_snapshot = snapshot_due && !is_rmc;
634                snapshot_due &= !take_snapshot;
635
636                let is_cmr = matches!(mode, crate::config::OverlapClusterBuildMode::Cmr);
637
638                let snap = if take_snapshot {
639                    for buf in spin_snap_buf.iter_mut() {
640                        buf.clear();
641                    }
642                    for buf in sid_snap_buf.iter_mut() {
643                        buf.clear();
644                    }
645                    Some(snap_buf.as_mut_slice())
646                } else {
647                    None
648                };
649                let blue_snap = if take_snapshot && is_cmr {
650                    Some(blue_snap_buf.as_mut_slice())
651                } else {
652                    None
653                };
654                let spin_snap = if take_snapshot {
655                    Some(spin_snap_buf.as_mut_slice())
656                } else {
657                    None
658                };
659                let sid_snap = if take_snapshot {
660                    Some(sid_snap_buf.as_mut_slice())
661                } else {
662                    None
663                };
664
665                if is_rmc {
666                    clusters::rmc_update(
667                        lattice,
668                        &mut real.spins,
669                        &real.couplings,
670                        &real.temperatures,
671                        &real.system_ids,
672                        n_replicas,
673                        n_temps,
674                        &overlap_active,
675                        &mut real.rngs,
676                        oc_cfg.cluster_mode,
677                        config.sequential,
678                        (collect_moves && record).then_some(rmc_slots.as_mut_slice()),
679                    );
680                } else {
681                    clusters::overlap_update(
682                        lattice,
683                        &mut real.spins,
684                        &real.couplings,
685                        &real.temperatures,
686                        &real.system_ids,
687                        n_replicas,
688                        n_temps,
689                        &overlap_active,
690                        &mut real.pair_rngs,
691                        mode,
692                        oc_cfg.cluster_mode,
693                        oc_cfg.action,
694                        ov_csd_out,
695                        top4_out,
696                        observation_out,
697                        config.sequential,
698                        snap,
699                        blue_snap,
700                        spin_snap,
701                        sid_snap,
702                        (collect_moves && record && n_pairs > 0)
703                            .then_some(move_slots[mode_idx].as_mut_slice()),
704                    );
705                }
706
707                if take_snapshot {
708                    let ids: Vec<Vec<u32>> = (0..n_temps)
709                        .map(|t| snap_buf[t * n_pairs].clone())
710                        .collect();
711                    let blue = if is_cmr {
712                        Some(
713                            (0..n_temps)
714                                .map(|t| blue_snap_buf[t * n_pairs].clone())
715                                .collect(),
716                        )
717                    } else {
718                        None
719                    };
720                    let spins: Vec<[Vec<i8>; 2]> = (0..n_temps)
721                        .map(|t| {
722                            spin_snap_buf[t * n_pairs]
723                                .first()
724                                .cloned()
725                                .unwrap_or_else(|| [vec![], vec![]])
726                        })
727                        .collect();
728                    let sids: Vec<[usize; 2]> = (0..n_temps)
729                        .map(|t| sid_snap_buf[t * n_pairs].first().copied().unwrap_or([0, 0]))
730                        .collect();
731                    cluster_snapshots.push(ClusterSnapshot {
732                        sweep_id,
733                        mode_idx,
734                        cluster_ids: ids,
735                        blue_ids: blue,
736                        spins,
737                        system_ids: sids,
738                    });
739                }
740
741                if collect_top && record {
742                    for t in 0..n_temps {
743                        for p in 0..n_pairs {
744                            let raw = top4_buf[t * n_pairs + p];
745                            for (k, &v) in raw.iter().enumerate() {
746                                top4_accum[mode_idx][t][k] += v as f64 / n_spins as f64;
747                            }
748                        }
749                    }
750                    top4_n[mode_idx] += 1;
751                }
752
753                if record && oc_cfg.action == ClusterAction::Observe {
754                    let accum = observation_accums.overlap_mut(mode);
755                    for (slot, observation) in overlap_observation_buf.iter().copied().enumerate() {
756                        accum.push(slot / n_pairs, observation);
757                    }
758                }
759            }
760        }
761
762        if pt_this_sweep {
763            cache.ensure(real);
764            real.temper(n_spins, n_replicas, n_temps, config.pt_schedule);
765        }
766        real.schedule.sweeps += 1;
767    })?;
768
769    let top_cluster_sizes: Vec<Vec<[f64; 4]>> = if collect_top {
770        let modes = &config.overlap_cluster.as_ref().unwrap().modes;
771        top4_accum
772            .iter()
773            .zip(top4_n.iter())
774            .zip(modes)
775            .map(|((mode_accum, &count), mode)| {
776                // Each temperature publishes one top-4 record per replica group.
777                let n_groups = match mode {
778                    OverlapClusterBuildMode::Rmc => 0,
779                    _ => n_replicas / mode.group_size(),
780                };
781                if count == 0 || n_groups == 0 {
782                    return vec![[0.0; 4]; n_temps];
783                }
784                let denom = (count * n_groups) as f64;
785                mode_accum
786                    .iter()
787                    .map(|arr| {
788                        [
789                            arr[0] / denom,
790                            arr[1] / denom,
791                            arr[2] / denom,
792                            arr[3] / denom,
793                        ]
794                    })
795                    .collect()
796            })
797            .collect()
798    } else {
799        vec![]
800    };
801
802    let (mags2_tau, mags2_tau_converged) = driver::tau_estimates(m2_accum.as_ref());
803    let (overlap2_tau, overlap2_tau_converged) = driver::tau_estimates(q2_accum.as_ref());
804    let (energy_tau, energy_tau_converged) = driver::tau_estimates(energy_accum.as_ref());
805
806    let equil_checkpoints = equil_accum.map(|acc| acc.finish()).unwrap_or_default();
807    // Wrapping is tracked along every axis of canonical square and cubic lattices.
808    let winding_dims = if lattice.square_shape().is_some() {
809        2
810    } else if lattice.cubic_shape().is_some() {
811        3
812    } else {
813        0
814    };
815
816    // Disabled histograms keep their shape only where the caller asked for it.
817    let keep_shape = |collected: bool| collected || materialize_disabled_stats;
818    let fk_csd = if keep_shape(collect_fk) {
819        fold_histograms(&fk_csd_slots, n_temps, n_spins, |slot| slot % n_temps)
820    } else {
821        vec![]
822    };
823    let overlap_csd: Vec<Vec<Vec<u64>>> = if keep_shape(collect_overlap) {
824        overlap_csd_slots
825            .iter()
826            .map(|slots| fold_histograms(slots, n_temps, n_spins, |slot| slot / n_pairs))
827            .collect()
828    } else {
829        vec![]
830    };
831    let cluster_observations = observation_accums.finish(
832        n_spins,
833        lattice.n_neighbors,
834        winding_dims,
835        &fk_csd,
836        config.overlap_cluster.as_ref().map_or(&[], |o| &o.modes),
837        &overlap_csd,
838    );
839
840    let overlap_stats = ov_accum.finish();
841    let mags2 = mags2_stat.average();
842    let mags4 = mags4_stat.average();
843    let energies = energies_stat.average();
844    let energy_variance = Statistics::variance(&energies_stat, &energies2_stat);
845    let sample = DisorderSample {
846        mags2: mags2.clone(),
847        mags4: mags4.clone(),
848        energies: energies.clone(),
849        energy_variance: energy_variance.clone(),
850        overlap2: overlap_stats.overlap2.clone(),
851        overlap4: overlap_stats.overlap4.clone(),
852        link_overlap: overlap_stats.link_overlap.clone(),
853        link_overlap2: overlap_stats.link_overlap2.clone(),
854        link_overlap4: overlap_stats.link_overlap4.clone(),
855        mags2_tau: mags2_tau.clone(),
856        overlap2_tau: overlap2_tau.clone(),
857        energy_tau: energy_tau.clone(),
858        mags2_tau_converged: mags2_tau_converged.clone(),
859        overlap2_tau_converged: overlap2_tau_converged.clone(),
860        energy_tau_converged: energy_tau_converged.clone(),
861    };
862
863    Ok(SweepResult {
864        mags: mags_stat.average(),
865        mags2,
866        mags4,
867        energies,
868        energies2: energies2_stat.average(),
869        energy_variance,
870        overlap_stats,
871        cluster_stats: ClusterStats {
872            fk_csd,
873            overlap_csd,
874            top_cluster_sizes,
875            fk_updates: if fk_updates_spins {
876                vec![fk_recorded_updates * n_replicas as u64; n_temps]
877            } else {
878                vec![]
879            },
880            overlap_moves: if collect_moves {
881                config
882                    .overlap_cluster
883                    .as_ref()
884                    .map_or(&[][..], |overlap| &overlap.modes)
885                    .iter()
886                    .zip(&move_slots)
887                    .map(|(mode, slots)| match mode {
888                        OverlapClusterBuildMode::Rmc => vec![],
889                        _ => fold_counts(
890                            slots,
891                            n_temps,
892                            |slot| slot / n_pairs.max(1),
893                            add_move_counts,
894                        ),
895                    })
896                    .collect()
897            } else {
898                vec![]
899            },
900            rmc_edges: if collect_moves
901                && config
902                    .overlap_cluster
903                    .as_ref()
904                    .is_some_and(|overlap| overlap.modes.contains(&OverlapClusterBuildMode::Rmc))
905            {
906                let mut edges =
907                    fold_counts(&rmc_slots, n_temps, |slot| slot % n_temps, add_rmc_counts);
908                edges.pop();
909                edges
910            } else {
911                vec![]
912            },
913            fk_visited_spins: if fk_updates_spins {
914                (0..n_temps)
915                    .map(|t| fk_visits.iter().skip(t).step_by(n_temps).sum())
916                    .collect()
917            } else {
918                vec![]
919            },
920        },
921        per_disorder_cluster_observations: vec![cluster_observations],
922        per_disorder_physics: physics.map(|p| vec![p.finish()]).unwrap_or_default(),
923        per_disorder: vec![sample],
924        diagnostics: Diagnostics {
925            mags2_tau,
926            overlap2_tau,
927            energy_tau,
928            mags2_tau_converged,
929            overlap2_tau_converged,
930            energy_tau_converged,
931            autocorrelation_lag: ac_max_lag,
932            equil_checkpoints,
933        },
934        cluster_snapshots,
935    })
936}
937
938/// Run the sweep loop in parallel over multiple disorder realizations.
939///
940/// The batch is validated before any realization changes, each realization then
941/// runs the same loop as [`run_sweep_loop`], and the results are averaged over
942/// disorder with the per-sample overlap histograms kept. Replicas run on the
943/// calling thread once the realizations alone fill the thread pool. A single
944/// realization is run directly, skipping rayon thread-pool overhead.
945pub fn run_sweep_parallel(
946    lattice: &Lattice,
947    realizations: &mut [Realization],
948    n_replicas: usize,
949    n_temps: usize,
950    config: &SimConfig,
951    interrupted: &AtomicBool,
952    on_sweep: &(dyn Fn() + Sync),
953) -> Result<SweepResult, String> {
954    run_sweep_parallel_with_physics(
955        lattice,
956        realizations,
957        n_replicas,
958        n_temps,
959        config,
960        interrupted,
961        on_sweep,
962        None,
963    )
964}
965
966/// Opt-in physical moments; the legacy entrypoint retains its measurement cost.
967#[allow(clippy::too_many_arguments)]
968pub fn run_sweep_parallel_with_physics(
969    lattice: &Lattice,
970    realizations: &mut [Realization],
971    n_replicas: usize,
972    n_temps: usize,
973    config: &SimConfig,
974    interrupted: &AtomicBool,
975    on_sweep: &(dyn Fn() + Sync),
976    physics: Option<&PhysicsOptions>,
977) -> Result<SweepResult, String> {
978    driver::validate_batch(lattice, realizations, n_replicas, n_temps, config)?;
979    if let Some(options) = physics {
980        options.validate(lattice, 1)?;
981    }
982    let single = realizations.len() == 1;
983    let mut effective = config.clone();
984    effective.sequential = driver::effective_sequential(config, realizations.len());
985    let config = &effective;
986    let mut results = driver::map_realizations(realizations, |idx, real| {
987        run_sweep_loop_impl(
988            lattice,
989            real,
990            n_replicas,
991            n_temps,
992            config,
993            interrupted,
994            on_sweep,
995            idx,
996            single,
997            physics,
998        )
999    })?;
1000    if single {
1001        return Ok(results.remove(0));
1002    }
1003    let snapshots = std::mem::take(&mut results[0].cluster_snapshots);
1004    let physics = results
1005        .iter_mut()
1006        .flat_map(|r| std::mem::take(&mut r.per_disorder_physics))
1007        .collect();
1008    let mut agg = SweepResult::aggregate_without_overlap_samples(&results);
1009    agg.per_disorder_physics = physics;
1010    if !agg.overlap_stats.overlap.is_empty() {
1011        agg.overlap_stats.per_sample_histogram = results
1012            .iter_mut()
1013            .map(|r| std::mem::take(&mut r.overlap_stats.histogram))
1014            .collect();
1015    }
1016    if !agg.overlap_stats.ql_at_q_sum.is_empty() {
1017        agg.overlap_stats.per_sample_ql_at_q_sum = results
1018            .iter_mut()
1019            .map(|r| std::mem::take(&mut r.overlap_stats.ql_at_q_sum))
1020            .collect();
1021        agg.overlap_stats.per_sample_ql2_at_q_sum = results
1022            .iter_mut()
1023            .map(|r| std::mem::take(&mut r.overlap_stats.ql2_at_q_sum))
1024            .collect();
1025    }
1026    if agg.cluster_stats.fk_csd.is_empty() {
1027        agg.cluster_stats.fk_csd = (0..n_temps)
1028            .map(|_| vec![0u64; lattice.n_spins + 1])
1029            .collect();
1030    }
1031    let n_modes = config.overlap_cluster.as_ref().map_or(0, |h| h.modes.len());
1032    if n_modes > 0 && agg.cluster_stats.overlap_csd.is_empty() {
1033        agg.cluster_stats.overlap_csd = (0..n_modes)
1034            .map(|_| {
1035                (0..n_temps)
1036                    .map(|_| vec![0u64; lattice.n_spins + 1])
1037                    .collect()
1038            })
1039            .collect();
1040    }
1041    agg.cluster_snapshots = snapshots;
1042    Ok(agg)
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048    use crate::config::{
1049        AutocorrelationBackend, ClusterConfig, ClusterMode, OverlapClusterBuildMode,
1050        OverlapClusterConfig, PtSchedule,
1051    };
1052
1053    fn run_with_cluster_stats(collect_stats: bool) -> SweepResult {
1054        let lattice = Lattice::new(vec![4, 4]);
1055        let couplings = vec![1.0; lattice.n_spins * lattice.n_neighbors];
1056        let mut realization = Realization::new(&lattice, couplings, &[2.0], 1, 42);
1057        let config = SimConfig {
1058            n_sweeps: 1,
1059            warmup_sweeps: 0,
1060            sweep_mode: SweepMode::Metropolis,
1061            cluster_update: Some(ClusterConfig {
1062                interval: 1,
1063                mode: ClusterMode::Sw,
1064                action: ClusterAction::Update,
1065                collect_stats,
1066            }),
1067            pt_interval: None,
1068            pt_schedule: PtSchedule::SingleRandomEdge,
1069            overlap_cluster: None,
1070            autocorrelation_max_lag: None,
1071            autocorrelation_backend: AutocorrelationBackend::Ring,
1072            sequential: true,
1073            equilibration_diagnostic: false,
1074            conditional_link_overlap: true,
1075        };
1076
1077        run_sweep_loop(
1078            &lattice,
1079            &mut realization,
1080            1,
1081            1,
1082            &config,
1083            &AtomicBool::new(false),
1084            &|| {},
1085            0,
1086        )
1087        .unwrap()
1088    }
1089
1090    fn plain_config(n_sweeps: usize) -> SimConfig {
1091        SimConfig {
1092            n_sweeps,
1093            warmup_sweeps: 0,
1094            sweep_mode: SweepMode::Metropolis,
1095            cluster_update: None,
1096            pt_interval: None,
1097            pt_schedule: PtSchedule::SingleRandomEdge,
1098            overlap_cluster: None,
1099            autocorrelation_max_lag: None,
1100            autocorrelation_backend: AutocorrelationBackend::Ring,
1101            sequential: true,
1102            equilibration_diagnostic: false,
1103            conditional_link_overlap: true,
1104        }
1105    }
1106
1107    fn run(
1108        lattice: &Lattice,
1109        real: &mut Realization,
1110        n_replicas: usize,
1111        config: &SimConfig,
1112    ) -> SweepResult {
1113        let n_temps = real.temperatures.len() / n_replicas;
1114        run_sweep_loop(
1115            lattice,
1116            real,
1117            n_replicas,
1118            n_temps,
1119            config,
1120            &AtomicBool::new(false),
1121            &|| {},
1122            0,
1123        )
1124        .unwrap()
1125    }
1126
1127    /// An event due every two sweeps must stay every two sweeps when the run is
1128    /// split into one-sweep calls.
1129    #[test]
1130    fn interval_phase_spans_calls() {
1131        let lattice = Lattice::new(vec![4, 4]);
1132        let couplings = vec![1.0; lattice.n_spins * lattice.n_neighbors];
1133        let mut real = Realization::new(&lattice, couplings, &[1.5, 2.5], 1, 9);
1134        let mut config = plain_config(1);
1135        config.pt_interval = Some(2);
1136        for _ in 0..6 {
1137            run(&lattice, &mut real, 1, &config);
1138        }
1139        assert_eq!(real.pt_edge_attempts(), &[3]);
1140
1141        real.reset(&lattice, 1, 2, 9);
1142        run(&lattice, &mut real, 1, &config);
1143        assert_eq!(real.pt_edge_attempts(), &[1], "reset restarts the phase");
1144    }
1145
1146    #[test]
1147    fn conditional_link_overlap_can_be_turned_off() {
1148        let lattice = Lattice::new(vec![4, 4]);
1149        let couplings = vec![1.0; lattice.n_spins * lattice.n_neighbors];
1150        let mut config = plain_config(4);
1151        for enabled in [true, false] {
1152            config.conditional_link_overlap = enabled;
1153            let mut real = Realization::new(&lattice, couplings.clone(), &[2.0], 2, 3);
1154            let stats = run(&lattice, &mut real, 2, &config).overlap_stats;
1155            assert_eq!(stats.histogram[0].iter().sum::<u64>(), 4);
1156            assert_eq!(stats.ql_at_q_sum.is_empty(), !enabled);
1157            assert_eq!(stats.ql2_at_q_sum.is_empty(), !enabled);
1158        }
1159    }
1160
1161    /// Move counters must be consistent: no update moves more sites than are active,
1162    /// and replica Monte Carlo accepts at most what it proposes.
1163    #[test]
1164    fn overlap_move_counters_are_consistent() {
1165        let lattice = Lattice::new(vec![6, 6]);
1166        let couplings: Vec<f32> = (0..lattice.n_spins * lattice.n_neighbors)
1167            .map(|i| if i % 3 == 0 { -1.0 } else { 1.0 })
1168            .collect();
1169        let temps = [0.8, 1.2, 2.0];
1170        let mut config = plain_config(60);
1171        config.warmup_sweeps = 10;
1172        config.pt_interval = Some(1);
1173        config.overlap_cluster = Some(OverlapClusterConfig {
1174            interval: 1,
1175            modes: vec![
1176                OverlapClusterBuildMode::Houdayer(2),
1177                OverlapClusterBuildMode::Cmr,
1178                OverlapClusterBuildMode::Rmc,
1179            ],
1180            cluster_mode: ClusterMode::Sw,
1181            action: ClusterAction::Update,
1182            collect_stats: true,
1183            snapshot_interval: None,
1184            max_temperature: None,
1185        });
1186        let mut real = Realization::new(&lattice, couplings, &temps, 2, 11);
1187        let stats = run(&lattice, &mut real, 2, &config).cluster_stats;
1188        assert_eq!(stats.overlap_moves.len(), 3);
1189        assert!(
1190            stats.overlap_moves[2].is_empty(),
1191            "rmc publishes no group moves"
1192        );
1193        // Recorded sweeps 10..60 rotate through the modes by sweep: Houdayer runs on
1194        // the 16 multiples of 3, CMR on 17 sweeps; one group per temperature.
1195        for (counts, calls) in stats.overlap_moves[..2].iter().zip([16, 17]) {
1196            for counts in counts {
1197                assert_eq!((counts.groups, counts.counted_groups), (calls, calls));
1198                assert!(counts.moved_sites <= counts.active_sites);
1199            }
1200        }
1201        let n = lattice.n_spins as u64;
1202        assert!(stats.overlap_moves[1]
1203            .iter()
1204            .all(|c| c.active_sites == 17 * n));
1205        assert_eq!(stats.rmc_edges.len(), temps.len() - 1);
1206        for edge in &stats.rmc_edges {
1207            assert!(edge.proposed > 0 && edge.accepted <= edge.proposed);
1208            assert!(edge.flipped_sites <= 2 * 16 * n);
1209        }
1210    }
1211
1212    /// A call that only thermalizes measures nothing, so its moments are undefined.
1213    #[test]
1214    fn warmup_only_calls_report_nan_moments() {
1215        let lattice = Lattice::new(vec![4, 4]);
1216        let couplings = vec![1.0; lattice.n_spins * lattice.n_neighbors];
1217        let mut real = Realization::new(&lattice, couplings, &[2.0], 2, 3);
1218        let mut config = plain_config(5);
1219        config.warmup_sweeps = 5;
1220        config.autocorrelation_max_lag = Some(2);
1221        let result = run(&lattice, &mut real, 2, &config);
1222        assert!(result.mags2[0].is_nan() && result.energies[0].is_nan());
1223        assert!(result.overlap_stats.overlap2[0].is_nan());
1224        assert!(result.diagnostics.mags2_tau[0].is_nan());
1225        assert_eq!(result.diagnostics.mags2_tau_converged, vec![false]);
1226        assert_eq!(result.per_disorder.len(), 1);
1227    }
1228
1229    /// Unit-coupling sweeps track energies from exact per-flip deltas; after a long run
1230    /// with tempering, overlap and cluster moves they must equal a fresh recomputation.
1231    #[test]
1232    fn tracked_energies_match_recomputation() {
1233        let lattice = Lattice::new(vec![6, 5]);
1234        let couplings: Vec<f32> = (0..lattice.n_spins * lattice.n_neighbors)
1235            .map(|i| [1.0, -1.0, 1.0, 0.0][i % 4])
1236            .collect();
1237        let temps = [0.6, 1.1, 1.9, 3.0];
1238        for (sweep_mode, with_clusters) in [
1239            (SweepMode::Metropolis, false),
1240            (SweepMode::Gibbs, false),
1241            (SweepMode::Metropolis, true),
1242        ] {
1243            let mut realization = Realization::new(&lattice, couplings.clone(), &temps, 2, 5);
1244            let config = SimConfig {
1245                n_sweeps: 300,
1246                warmup_sweeps: 100,
1247                sweep_mode,
1248                cluster_update: with_clusters.then_some(ClusterConfig {
1249                    interval: 7,
1250                    mode: ClusterMode::Sw,
1251                    action: ClusterAction::Update,
1252                    collect_stats: false,
1253                }),
1254                pt_interval: Some(1),
1255                pt_schedule: PtSchedule::FullLadder,
1256                overlap_cluster: with_clusters.then_some(OverlapClusterConfig {
1257                    interval: 5,
1258                    modes: vec![OverlapClusterBuildMode::Cmr, OverlapClusterBuildMode::Rmc],
1259                    cluster_mode: ClusterMode::Sw,
1260                    action: ClusterAction::Update,
1261                    collect_stats: false,
1262                    snapshot_interval: None,
1263                    max_temperature: None,
1264                }),
1265                autocorrelation_max_lag: None,
1266                autocorrelation_backend: AutocorrelationBackend::Ring,
1267                sequential: false,
1268                equilibration_diagnostic: false,
1269                conditional_link_overlap: true,
1270            };
1271            run_sweep_loop(
1272                &lattice,
1273                &mut realization,
1274                2,
1275                temps.len(),
1276                &config,
1277                &AtomicBool::new(false),
1278                &|| {},
1279                0,
1280            )
1281            .unwrap();
1282            let mut expected = vec![0.0; realization.energies.len()];
1283            spins::energy::compute_energies_into(
1284                &lattice,
1285                &realization.spins,
1286                &realization.couplings,
1287                &mut expected,
1288            );
1289            // Cluster intervals 7 and 5 skip the final sweep (index 299), whose tempering
1290            // step refreshes observables after the last mutating move.
1291            assert_eq!(
1292                realization.energies, expected,
1293                "{sweep_mode:?} clusters={with_clusters}"
1294            );
1295        }
1296    }
1297
1298    /// Overlap moves above `max_temperature` must leave those systems untouched, while
1299    /// the colder temperatures keep moving.
1300    #[test]
1301    fn overlap_moves_respect_max_temperature() {
1302        let lattice = Lattice::new(vec![6, 6]);
1303        let couplings: Vec<f32> = (0..lattice.n_spins * lattice.n_neighbors)
1304            .map(|i| if i % 3 == 0 { -1.0 } else { 1.0 })
1305            .collect();
1306        let temps = [0.5, 1.0, 3.0];
1307        for mode in [
1308            OverlapClusterBuildMode::Houdayer(2),
1309            OverlapClusterBuildMode::Rmc,
1310        ] {
1311            let mut realization = Realization::new(&lattice, couplings.clone(), &temps, 2, 31);
1312            let initial = realization.spins.clone();
1313            let config = SimConfig {
1314                n_sweeps: 20,
1315                warmup_sweeps: 0,
1316                sweep_mode: SweepMode::None,
1317                cluster_update: None,
1318                pt_interval: None,
1319                pt_schedule: PtSchedule::SingleRandomEdge,
1320                overlap_cluster: Some(OverlapClusterConfig {
1321                    interval: 1,
1322                    modes: vec![mode],
1323                    cluster_mode: ClusterMode::Sw,
1324                    action: ClusterAction::Update,
1325                    collect_stats: false,
1326                    snapshot_interval: None,
1327                    max_temperature: Some(1.0),
1328                }),
1329                autocorrelation_max_lag: None,
1330                autocorrelation_backend: AutocorrelationBackend::Ring,
1331                sequential: true,
1332                equilibration_diagnostic: false,
1333                conditional_link_overlap: true,
1334            };
1335            run_sweep_loop(
1336                &lattice,
1337                &mut realization,
1338                2,
1339                temps.len(),
1340                &config,
1341                &AtomicBool::new(false),
1342                &|| {},
1343                0,
1344            )
1345            .unwrap();
1346            let n = lattice.n_spins;
1347            let changed = |t: usize| {
1348                (0..2).any(|r| {
1349                    let system = realization.system_ids[r * temps.len() + t];
1350                    realization.spins[system * n..(system + 1) * n]
1351                        != initial[system * n..(system + 1) * n]
1352                })
1353            };
1354            assert!(!changed(2), "{mode:?} moved the T = 3.0 systems");
1355            assert!(changed(0), "{mode:?} never moved the T = 0.5 systems");
1356        }
1357    }
1358
1359    #[test]
1360    fn disabled_cluster_histograms_preserve_public_shape() {
1361        let without_stats = run_with_cluster_stats(false);
1362        assert_eq!(without_stats.cluster_stats.fk_csd.len(), 1);
1363        assert_eq!(without_stats.cluster_stats.fk_csd[0].iter().sum::<u64>(), 0);
1364
1365        let with_stats = run_with_cluster_stats(true);
1366        assert_eq!(with_stats.cluster_stats.fk_csd.len(), 1);
1367        assert!(with_stats.cluster_stats.fk_csd[0].iter().sum::<u64>() > 0);
1368    }
1369
1370    #[test]
1371    fn disabled_overlap_histograms_preserve_public_shape() {
1372        let lattice = Lattice::new(vec![4, 4]);
1373        let couplings = vec![1.0; lattice.n_spins * lattice.n_neighbors];
1374
1375        for collect_stats in [false, true] {
1376            let mut realization = Realization::new(&lattice, couplings.clone(), &[2.0], 2, 42);
1377            let config = SimConfig {
1378                n_sweeps: 1,
1379                warmup_sweeps: 0,
1380                sweep_mode: SweepMode::Metropolis,
1381                cluster_update: None,
1382                pt_interval: None,
1383                pt_schedule: PtSchedule::SingleRandomEdge,
1384                overlap_cluster: Some(OverlapClusterConfig {
1385                    interval: 1,
1386                    modes: vec![OverlapClusterBuildMode::Houdayer(2)],
1387                    cluster_mode: ClusterMode::Sw,
1388                    action: ClusterAction::Update,
1389                    collect_stats,
1390                    snapshot_interval: None,
1391                    max_temperature: None,
1392                }),
1393                autocorrelation_max_lag: None,
1394                autocorrelation_backend: AutocorrelationBackend::Ring,
1395                sequential: true,
1396                equilibration_diagnostic: false,
1397                conditional_link_overlap: true,
1398            };
1399
1400            let result = run_sweep_loop(
1401                &lattice,
1402                &mut realization,
1403                2,
1404                1,
1405                &config,
1406                &AtomicBool::new(false),
1407                &|| {},
1408                0,
1409            )
1410            .unwrap();
1411
1412            if collect_stats {
1413                assert_eq!(result.cluster_stats.overlap_csd.len(), 1);
1414                assert!(result.cluster_stats.overlap_csd[0][0].iter().sum::<u64>() > 0);
1415            } else {
1416                assert_eq!(result.cluster_stats.overlap_csd.len(), 1);
1417                assert_eq!(
1418                    result.cluster_stats.overlap_csd[0][0].iter().sum::<u64>(),
1419                    0
1420                );
1421            }
1422        }
1423    }
1424
1425    /// Top-4 fractions average over the replica groups that publish them: one group of
1426    /// four replicas yields the fractions of its own graph, not half of them.
1427    #[test]
1428    fn top_cluster_sizes_average_over_replica_groups() {
1429        let lattice = Lattice::new(vec![6, 6]);
1430        let n = lattice.n_spins;
1431        let couplings = vec![1.0; n * lattice.n_neighbors];
1432        let mut realization = Realization::new(&lattice, couplings, &[2.0], 4, 3);
1433        // The balanced-site graph does not depend on how the group is shuffled.
1434        let balanced: Vec<bool> = (0..n)
1435            .map(|i| {
1436                (0..4)
1437                    .map(|r| realization.spins[r * n + i] as i32)
1438                    .sum::<i32>()
1439                    == 0
1440            })
1441            .collect();
1442        let mut component = vec![usize::MAX; n];
1443        let mut sizes = Vec::new();
1444        for start in 0..n {
1445            if component[start] != usize::MAX {
1446                continue;
1447            }
1448            component[start] = sizes.len();
1449            let (mut stack, mut size) = (vec![start], 0);
1450            while let Some(i) = stack.pop() {
1451                size += 1;
1452                for d in 0..lattice.n_neighbors {
1453                    for j in [lattice.neighbor_fwd(i, d), lattice.neighbor_bwd(i, d)] {
1454                        if balanced[i] && balanced[j] && component[j] == usize::MAX {
1455                            component[j] = sizes.len();
1456                            stack.push(j);
1457                        }
1458                    }
1459                }
1460            }
1461            sizes.push(size);
1462        }
1463        sizes.sort_unstable_by(|a, b| b.cmp(a));
1464        let expected: Vec<f64> = sizes[..4].iter().map(|&s| s as f64 / n as f64).collect();
1465
1466        let config = SimConfig {
1467            n_sweeps: 1,
1468            warmup_sweeps: 0,
1469            sweep_mode: SweepMode::None,
1470            cluster_update: None,
1471            pt_interval: None,
1472            pt_schedule: PtSchedule::SingleRandomEdge,
1473            overlap_cluster: Some(OverlapClusterConfig {
1474                interval: 1,
1475                modes: vec![OverlapClusterBuildMode::Houdayer(4)],
1476                cluster_mode: ClusterMode::Sw,
1477                action: ClusterAction::Update,
1478                collect_stats: true,
1479                snapshot_interval: None,
1480                max_temperature: None,
1481            }),
1482            autocorrelation_max_lag: None,
1483            autocorrelation_backend: AutocorrelationBackend::Ring,
1484            sequential: true,
1485            equilibration_diagnostic: false,
1486            conditional_link_overlap: true,
1487        };
1488        let result = run_sweep_loop(
1489            &lattice,
1490            &mut realization,
1491            4,
1492            1,
1493            &config,
1494            &AtomicBool::new(false),
1495            &|| {},
1496            0,
1497        )
1498        .unwrap();
1499        assert_eq!(
1500            result.cluster_stats.top_cluster_sizes[0][0].to_vec(),
1501            expected
1502        );
1503    }
1504
1505    #[test]
1506    fn fk_observe_records_without_changing_spins() {
1507        let lattice = Lattice::new(vec![4, 4]);
1508        let couplings = vec![1.0; lattice.n_spins * lattice.n_neighbors];
1509        let mut observed = Realization::new(&lattice, couplings.clone(), &[2.0], 1, 19);
1510        let mut baseline = Realization::new(&lattice, couplings, &[2.0], 1, 19);
1511        let mut config = SimConfig {
1512            n_sweeps: 1,
1513            warmup_sweeps: 0,
1514            sweep_mode: SweepMode::Metropolis,
1515            cluster_update: None,
1516            pt_interval: None,
1517            pt_schedule: PtSchedule::SingleRandomEdge,
1518            overlap_cluster: None,
1519            autocorrelation_max_lag: None,
1520            autocorrelation_backend: AutocorrelationBackend::Ring,
1521            sequential: true,
1522            equilibration_diagnostic: false,
1523            conditional_link_overlap: true,
1524        };
1525        run_sweep_loop(
1526            &lattice,
1527            &mut baseline,
1528            1,
1529            1,
1530            &config,
1531            &AtomicBool::new(false),
1532            &|| {},
1533            0,
1534        )
1535        .unwrap();
1536
1537        config.cluster_update = Some(ClusterConfig {
1538            interval: 1,
1539            mode: ClusterMode::Sw,
1540            action: ClusterAction::Observe,
1541            collect_stats: true,
1542        });
1543        let result = run_sweep_loop(
1544            &lattice,
1545            &mut observed,
1546            1,
1547            1,
1548            &config,
1549            &AtomicBool::new(false),
1550            &|| {},
1551            0,
1552        )
1553        .unwrap();
1554
1555        assert_eq!(observed.spins, baseline.spins);
1556        assert_eq!(observed.system_ids, baseline.system_ids);
1557        let summary = result.per_disorder_cluster_observations[0]
1558            .fk
1559            .as_ref()
1560            .unwrap();
1561        assert_eq!(summary.observation_count, vec![1]);
1562        assert_eq!(
1563            summary.cluster_size_counts[0]
1564                .iter()
1565                .enumerate()
1566                .map(|(size, &count)| size as u64 * count)
1567                .sum::<u64>(),
1568            lattice.n_spins as u64
1569        );
1570    }
1571
1572    #[test]
1573    fn cmr_observe_stops_before_cluster_actions() {
1574        let lattice = Lattice::new(vec![4, 4]);
1575        let couplings = vec![1.0; lattice.n_spins * lattice.n_neighbors];
1576        let mut observed = Realization::new(&lattice, couplings.clone(), &[1.5], 2, 23);
1577        let mut baseline = Realization::new(&lattice, couplings, &[1.5], 2, 23);
1578        let mut config = SimConfig {
1579            n_sweeps: 1,
1580            warmup_sweeps: 0,
1581            sweep_mode: SweepMode::Metropolis,
1582            cluster_update: None,
1583            pt_interval: None,
1584            pt_schedule: PtSchedule::SingleRandomEdge,
1585            overlap_cluster: None,
1586            autocorrelation_max_lag: None,
1587            autocorrelation_backend: AutocorrelationBackend::Ring,
1588            sequential: true,
1589            equilibration_diagnostic: false,
1590            conditional_link_overlap: true,
1591        };
1592        run_sweep_loop(
1593            &lattice,
1594            &mut baseline,
1595            2,
1596            1,
1597            &config,
1598            &AtomicBool::new(false),
1599            &|| {},
1600            0,
1601        )
1602        .unwrap();
1603
1604        config.overlap_cluster = Some(OverlapClusterConfig {
1605            interval: 1,
1606            modes: vec![OverlapClusterBuildMode::Cmr],
1607            cluster_mode: ClusterMode::Sw,
1608            action: ClusterAction::Observe,
1609            collect_stats: true,
1610            snapshot_interval: None,
1611            max_temperature: None,
1612        });
1613        let result = run_sweep_loop(
1614            &lattice,
1615            &mut observed,
1616            2,
1617            1,
1618            &config,
1619            &AtomicBool::new(false),
1620            &|| {},
1621            0,
1622        )
1623        .unwrap();
1624
1625        assert_eq!(observed.spins, baseline.spins);
1626        assert_eq!(observed.system_ids, baseline.system_ids);
1627        assert_eq!(
1628            result.per_disorder_cluster_observations[0]
1629                .cmr_blue
1630                .as_ref()
1631                .unwrap()
1632                .observation_count,
1633            vec![1]
1634        );
1635    }
1636}