Skip to main content

spin_sim/statistics/
physics.rs

1//! Physical moments with thermal variances formed before disorder averaging.
2use crate::{
3    geometry::Lattice,
4    spins::{
5        model::{Real, Spin},
6        xy::{cross, dot},
7    },
8};
9use std::{
10    collections::BTreeMap,
11    f64::consts::{PI, TAU},
12};
13
14#[derive(Clone, Debug, Default)]
15pub struct PhysicsOptions {
16    pub displacements: Vec<Vec<isize>>,
17    pub vortices: bool,
18    /// Measured sweeps per block, including all replicas. None disables storage.
19    pub block_size: Option<usize>,
20}
21
22impl PhysicsOptions {
23    /// Checks these options against a lattice without allocating a collector.
24    pub fn validate(&self, lattice: &Lattice, components: usize) -> Result<(), String> {
25        if self.block_size == Some(0) {
26            return Err("block_size must be positive".into());
27        }
28        if self.vortices && (lattice.n_dims != 2 || components != 2) {
29            return Err("angle vortices require a two-dimensional XY lattice".into());
30        }
31        if self.displacements.iter().any(|r| r.len() != lattice.n_dims) {
32            return Err("correlation displacements must match lattice dimensionality".into());
33        }
34        Ok(())
35    }
36}
37
38/// Energy and magnetization of one configuration, already known to the caller.
39#[derive(Clone, Copy, Debug)]
40pub struct CachedTotals {
41    /// Physical energy per site, `H/N`.
42    pub energy: f64,
43    /// Magnetization summed over occupied sites.
44    pub magnetization: [f64; 2],
45}
46
47#[derive(Clone, Debug)]
48pub struct MomentField {
49    pub name: &'static str,
50    pub width: usize,
51    pub offset: usize,
52}
53
54#[derive(Clone, Debug)]
55pub struct MomentBlock {
56    /// Sum over configurations, indexed by temperature then flattened moment.
57    pub sums: Vec<Vec<f64>>,
58    /// Configurations per temperature, including replicas and any partial block.
59    pub count: u64,
60    pub sweeps: usize,
61}
62
63/// Per-disorder moments averaged over measured configurations.
64///
65/// `energies` is the physical energy per site `+H/N`, the opposite sign of
66/// [`super::SweepResult::energies`] (`-H/N`).
67#[derive(Clone, Debug)]
68pub struct PhysicsResult {
69    pub fields: Vec<MomentField>,
70    pub moments: Vec<Vec<f64>>,
71    pub count: u64,
72    pub blocks: Vec<MomentBlock>,
73}
74
75pub type PhysicsValues = BTreeMap<&'static str, Vec<Vec<f64>>>;
76impl PhysicsResult {
77    pub fn values(
78        &self,
79        shape: &[usize],
80        temperatures: &[f64],
81        components: usize,
82    ) -> PhysicsValues {
83        let n: f64 = shape.iter().product::<usize>() as f64;
84        let mut out: PhysicsValues = self
85            .fields
86            .iter()
87            .map(|f| {
88                (
89                    f.name,
90                    self.moments
91                        .iter()
92                        .map(|row| row[f.offset..f.offset + f.width].to_vec())
93                        .collect(),
94                )
95            })
96            .collect();
97        let mut heat = Vec::new();
98        let mut binder = Vec::new();
99        let mut s0 = Vec::new();
100        let mut chi = Vec::new();
101        let mut helicity = Vec::new();
102        let mut xi = Vec::new();
103        let mut xi_ratio = Vec::new();
104        for (t, &temperature) in temperatures.iter().enumerate() {
105            let beta = 1.0 / temperature;
106            let m2 = out["mags2"][t][0];
107            heat.push(vec![
108                beta * beta * n * (out["energies2"][t][0] - out["energies"][t][0].powi(2)),
109            ]);
110            let denominator = (components as f64 + 2.0) / components as f64 * m2 * m2;
111            binder.push(vec![if denominator > 0.0 {
112                1.0 - out["mags4"][t][0] / denominator
113            } else {
114                f64::NAN
115            }]);
116            s0.push(vec![n * m2]);
117            chi.push(vec![beta * n * m2 / components as f64]);
118            let (length, ratio) =
119                second_moment_lengths(n * m2, &out["structure_factor_min"][t], shape);
120            if components == 2 {
121                helicity.push(
122                    (0..shape.len())
123                        .map(|d| (out["helicity_d"][t][d] - beta * out["helicity_i2"][t][d]) / n)
124                        .collect(),
125                );
126            }
127            xi.push(length);
128            xi_ratio.push(ratio);
129        }
130        if out.contains_key("overlap2") {
131            let mut sg_binder = Vec::new();
132            let mut sg_s0 = Vec::new();
133            let mut sg_xi = Vec::new();
134            let mut sg_xi_ratio = Vec::new();
135            let rows = out["overlap2"]
136                .iter()
137                .zip(&out["overlap4"])
138                .zip(&out["overlap_structure_factor_min"]);
139            for ((q2, q4), sk_min) in rows {
140                let (q2, q4) = (q2[0], q4[0]);
141                sg_binder.push(vec![if q2 > 0.0 {
142                    1.0 - q4 / (3.0 * q2 * q2)
143                } else {
144                    f64::NAN
145                }]);
146                sg_s0.push(vec![n * q2]);
147                let (length, ratio) = second_moment_lengths(n * q2, sk_min, shape);
148                sg_xi.push(length);
149                sg_xi_ratio.push(ratio);
150            }
151            out.insert("sg_binder", sg_binder);
152            out.insert("sg_structure_factor_0", sg_s0);
153            out.insert("sg_correlation_length", sg_xi);
154            out.insert("sg_correlation_length_ratio", sg_xi_ratio);
155        }
156        for (name, values) in [
157            ("heat_capacity", heat),
158            ("binder_cumulant", binder),
159            ("structure_factor_0", s0),
160            ("susceptibility", chi),
161            ("correlation_length", xi),
162            ("correlation_length_ratio", xi_ratio),
163        ] {
164            out.insert(name, values);
165        }
166        if components == 2 {
167            out.insert("helicity_modulus", helicity);
168        }
169        out
170    }
171
172    /// Equal disorder weights for raw moments; heat capacities retain thermal centering.
173    pub fn aggregate<'a>(
174        results: impl IntoIterator<Item = &'a Self>,
175        shape: &[usize],
176        temperatures: &[f64],
177        components: usize,
178    ) -> PhysicsValues {
179        let results: Vec<_> = results.into_iter().collect();
180        assert!(!results.is_empty());
181        let mut pooled = Self {
182            fields: results[0].fields.clone(),
183            moments: vec![vec![0.0; results[0].moments[0].len()]; temperatures.len()],
184            count: 0,
185            blocks: vec![],
186        };
187        for result in &results {
188            for (dst, src) in pooled.moments.iter_mut().zip(&result.moments) {
189                for (a, b) in dst.iter_mut().zip(src) {
190                    *a += b / results.len() as f64;
191                }
192            }
193        }
194        let mut out = pooled.values(shape, temperatures, components);
195        let mut heat = vec![vec![0.0]; temperatures.len()];
196        for result in &results {
197            let values = result.values(shape, temperatures, components);
198            for (dst, src) in heat.iter_mut().zip(&values["heat_capacity"]) {
199                dst[0] += src[0] / results.len() as f64;
200            }
201        }
202        out.insert("heat_capacity", heat);
203        out
204    }
205}
206
207impl PhysicsResult {
208    /// Delete-one-disorder jackknife standard errors of every value of
209    /// [`Self::aggregate`]. Ratios of disorder averages (Binder parameters,
210    /// correlation lengths) have no error bar from the samples' own ratios; the
211    /// jackknife recomputes them with each sample left out. NaN with fewer than two
212    /// samples, and wherever a leave-one-out value is undefined.
213    pub fn jackknife_stderr<'a>(
214        results: impl IntoIterator<Item = &'a Self>,
215        shape: &[usize],
216        temperatures: &[f64],
217        components: usize,
218    ) -> PhysicsValues {
219        let results: Vec<_> = results.into_iter().collect();
220        assert!(!results.is_empty());
221        let n = results.len();
222        let full = Self::aggregate(results.iter().copied(), shape, temperatures, components);
223        let undefined = |values: &PhysicsValues| -> PhysicsValues {
224            values
225                .iter()
226                .map(|(&name, rows)| {
227                    (
228                        name,
229                        rows.iter().map(|row| vec![f64::NAN; row.len()]).collect(),
230                    )
231                })
232                .collect()
233        };
234        if n < 2 {
235            return undefined(&full);
236        }
237
238        let width = results[0].moments[0].len();
239        let mut total = vec![vec![0.0; width]; temperatures.len()];
240        for result in &results {
241            for (sum, moment) in total
242                .iter_mut()
243                .flatten()
244                .zip(result.moments.iter().flatten())
245            {
246                *sum += moment;
247            }
248        }
249        // Heat capacities are centred within each sample, so they are averaged apart.
250        let heats: Vec<Vec<f64>> = results
251            .iter()
252            .map(|result| {
253                result.values(shape, temperatures, components)["heat_capacity"]
254                    .iter()
255                    .map(|row| row[0])
256                    .collect()
257            })
258            .collect();
259        let heat_total: Vec<f64> = (0..temperatures.len())
260            .map(|t| heats.iter().map(|heat| heat[t]).sum())
261            .collect();
262
263        let left_out = (n - 1) as f64;
264        let leave_one_out: Vec<PhysicsValues> = (0..n)
265            .map(|omitted| {
266                let pooled = Self {
267                    fields: results[0].fields.clone(),
268                    moments: total
269                        .iter()
270                        .zip(&results[omitted].moments)
271                        .map(|(sum, moment)| {
272                            sum.iter()
273                                .zip(moment)
274                                .map(|(sum, moment)| (sum - moment) / left_out)
275                                .collect()
276                        })
277                        .collect(),
278                    count: 0,
279                    blocks: vec![],
280                };
281                let mut values = pooled.values(shape, temperatures, components);
282                let heat = (0..temperatures.len())
283                    .map(|t| vec![(heat_total[t] - heats[omitted][t]) / left_out])
284                    .collect();
285                values.insert("heat_capacity", heat);
286                values
287            })
288            .collect();
289
290        // Var = (n - 1) / n · Σ_i (θ_(i) - θ_(·))², with θ_(·) the leave-one-out mean.
291        let mut stderr = undefined(&full);
292        for (&name, rows) in stderr.iter_mut() {
293            for (t, row) in rows.iter_mut().enumerate() {
294                for (k, error) in row.iter_mut().enumerate() {
295                    let estimates = leave_one_out.iter().map(|values| values[name][t][k]);
296                    let mean = estimates.clone().sum::<f64>() / n as f64;
297                    let spread: f64 = estimates.map(|value| (value - mean).powi(2)).sum();
298                    *error = (left_out / n as f64 * spread).sqrt();
299                }
300            }
301        }
302        stderr
303    }
304}
305
306/// Second-moment correlation lengths `ξ_d` and `ξ_d / L_d` along each axis, from
307/// the structure factor at zero and at the smallest nonzero wavevector `2π/L_d`.
308fn second_moment_lengths(s0: f64, sk_min: &[f64], shape: &[usize]) -> (Vec<f64>, Vec<f64>) {
309    shape
310        .iter()
311        .zip(sk_min)
312        .map(|(&extent, &sk)| {
313            let radicand = s0 / sk - 1.0;
314            let length = if sk > 0.0 && radicand >= 0.0 {
315                radicand.sqrt() / (2.0 * (PI / extent as f64).sin())
316            } else {
317                f64::NAN
318            };
319            (length, length / extent as f64)
320        })
321        .unzip()
322}
323
324/// Geometry and Fourier phases shared by every measurement of a run.
325pub struct PhysicsCollector {
326    fields: Vec<MomentField>,
327    /// `phases[d][x] = (cos, sin)(2π x / L_d)`.
328    phases: Vec<Vec<[f64; 2]>>,
329    /// Per-dimension spin sums over the hyperplanes `x_d = x`, `[n_dims][L_d]`.
330    planes: Vec<Vec<[f64; 2]>>,
331    /// Per-site spin angles for vortex detection; empty unless vortices are enabled.
332    angles: Vec<f64>,
333    displaced: Vec<Vec<usize>>,
334    options: PhysicsOptions,
335    components: usize,
336    sums: Vec<Vec<f64>>,
337    block_sums: Vec<Vec<f64>>,
338    /// Temperatures whose block rows are stored; empty means all.
339    block_temperatures: Vec<bool>,
340    block_count: u64,
341    block_sweeps: usize,
342    blocks: Vec<MomentBlock>,
343    count: u64,
344    scratch: Vec<f64>,
345    /// First column of the replica-overlap moments, when enabled.
346    overlap_offset: Option<usize>,
347    /// Site overlaps `q_i = s_i^a s_i^b` of the pair being measured.
348    overlap_sites: Vec<i8>,
349}
350
351impl PhysicsCollector {
352    pub fn new(
353        lattice: &Lattice,
354        n_temps: usize,
355        options: &PhysicsOptions,
356        components: usize,
357    ) -> Result<Self, String> {
358        options.validate(lattice, components)?;
359        let mut fields = Vec::new();
360        let mut width = 0;
361        let mut add = |name, n| {
362            fields.push(MomentField {
363                name,
364                width: n,
365                offset: width,
366            });
367            width += n;
368        };
369        for name in ["energies", "energies2", "mags", "mags2", "mags4"] {
370            add(name, 1);
371        }
372        if components == 2 {
373            for name in ["helicity_d", "helicity_i", "helicity_i2"] {
374                add(name, lattice.n_dims);
375            }
376        }
377        add("structure_factor_min", lattice.n_dims);
378        if !options.displacements.is_empty() {
379            add("correlations", options.displacements.len());
380        }
381        if options.vortices {
382            add("angle_vortex_density", 1);
383            add("intact_plaquette_fraction", 1);
384        }
385        let phases = lattice
386            .shape
387            .iter()
388            .map(|&extent| {
389                (0..extent)
390                    .map(|x| {
391                        let (s, c) = (TAU * x as f64 / extent as f64).sin_cos();
392                        [c, s]
393                    })
394                    .collect()
395            })
396            .collect();
397        let planes = lattice
398            .shape
399            .iter()
400            .map(|&extent| vec![[0.0; 2]; extent])
401            .collect();
402        let displaced = options
403            .displacements
404            .iter()
405            .map(|r| {
406                (0..lattice.n_spins)
407                    .map(|i| {
408                        r.iter()
409                            .enumerate()
410                            .map(|(d, &delta)| {
411                                let extent = lattice.shape[d] as isize;
412                                let coordinate =
413                                    ((i / lattice.strides[d]) % lattice.shape[d]) as isize;
414                                ((coordinate + delta.rem_euclid(extent)).rem_euclid(extent)
415                                    as usize)
416                                    * lattice.strides[d]
417                            })
418                            .sum()
419                    })
420                    .collect()
421            })
422            .collect();
423        Ok(Self {
424            fields,
425            phases,
426            planes,
427            angles: if options.vortices {
428                vec![0.0; lattice.n_spins]
429            } else {
430                vec![]
431            },
432            displaced,
433            options: options.clone(),
434            components,
435            sums: vec![vec![0.0; width]; n_temps],
436            block_sums: if options.block_size.is_some() {
437                vec![vec![0.0; width]; n_temps]
438            } else {
439                vec![]
440            },
441            block_temperatures: vec![],
442            block_count: 0,
443            block_sweeps: 0,
444            blocks: vec![],
445            count: 0,
446            scratch: vec![0.0; width],
447            overlap_offset: None,
448            overlap_sites: vec![],
449        })
450    }
451
452    /// Adds replica-overlap moments `overlap2`, `overlap4` and
453    /// `overlap_structure_factor_min`, filled by [`Self::measure_overlap`], and
454    /// `overlap2_all_pairs`, filled by [`Self::measure_overlap_all_pairs`]. Call
455    /// before the first measurement.
456    pub fn with_overlap(mut self, lattice: &Lattice) -> Self {
457        let offset = self.scratch.len();
458        let mut width = offset;
459        for (name, n) in [
460            ("overlap2", 1),
461            ("overlap4", 1),
462            ("overlap_structure_factor_min", lattice.n_dims),
463            ("overlap2_all_pairs", 1),
464        ] {
465            self.fields.push(MomentField {
466                name,
467                width: n,
468                offset: width,
469            });
470            width += n;
471        }
472        for row in self.sums.iter_mut().chain(&mut self.block_sums) {
473            row.resize(width, 0.0);
474        }
475        self.scratch.resize(width, 0.0);
476        self.overlap_offset = Some(offset);
477        self.overlap_sites = vec![0; lattice.n_spins];
478        self
479    }
480
481    /// Accumulates the overlap `q_i = a_i b_i` of one replica pair at `temperature`.
482    ///
483    /// [`Self::end_sweep`] counts replicas, not pairs, so `weight` should be
484    /// replicas per measured pair to normalize these moments per pair.
485    pub fn measure_overlap(
486        &mut self,
487        lattice: &Lattice,
488        a: &[i8],
489        b: &[i8],
490        occupied: &[bool],
491        temperature: usize,
492        weight: f64,
493    ) {
494        let Some(offset) = self.overlap_offset else {
495            return;
496        };
497        let mut sites = std::mem::take(&mut self.overlap_sites);
498        for ((q, &x), &y) in sites.iter_mut().zip(a).zip(b) {
499            *q = x * y;
500        }
501        let n = lattice.n_spins as f64;
502        let q = sites.iter().map(|&v| i64::from(v)).sum::<i64>() as f64 / n;
503        self.accumulate_planes(lattice, &sites, occupied);
504        self.overlap_sites = sites;
505        self.scratch[offset] = q * q;
506        self.scratch[offset + 1] = q.powi(4);
507        for d in 0..lattice.n_dims {
508            self.scratch[offset + 2 + d] = self.plane_structure_factor(d, n);
509        }
510        let end = offset + 2 + lattice.n_dims;
511        let values = &self.scratch[offset..end];
512        let rows = std::iter::once(&mut self.sums[temperature]);
513        let block_rows = self.block_sums.get_mut(temperature);
514        for row in rows.chain(block_rows) {
515            for (dst, &value) in row[offset..end].iter_mut().zip(values) {
516                *dst += weight * value;
517            }
518        }
519    }
520
521    /// Accumulates Q2, `q²` averaged over every pair of `replicas`, at
522    /// `temperature`. Unlike the fixed pairs of [`Self::measure_overlap`], Q2 does
523    /// not change when replicas are relabeled. `weight` is the replica count that
524    /// [`Self::end_sweep`] adds per sweep.
525    pub fn measure_overlap_all_pairs(
526        &mut self,
527        replicas: &[&[i8]],
528        temperature: usize,
529        weight: f64,
530    ) {
531        let Some(offset) = self.overlap_offset else {
532            return;
533        };
534        let (sum, pairs) = super::overlap::pair_q2_sum(replicas.len(), |r| replicas[r]);
535        if pairs == 0 {
536            return;
537        }
538        let column = offset + 2 + self.planes.len();
539        let value = weight * sum / pairs as f64;
540        self.sums[temperature][column] += value;
541        if let Some(row) = self.block_sums.get_mut(temperature) {
542            row[column] += value;
543        }
544    }
545
546    /// `|Σ_x P_d(x) e^{2πi x/L_d}|² / N` for the hyperplane sums from
547    /// [`Self::accumulate_planes`].
548    fn plane_structure_factor(&self, d: usize, n: f64) -> f64 {
549        let mut fourier = [[0.0; 2]; 2];
550        for (plane, phase) in self.planes[d].iter().zip(&self.phases[d]) {
551            for (row, &value) in fourier.iter_mut().zip(plane) {
552                for (entry, &phase) in row.iter_mut().zip(phase) {
553                    *entry += value * phase;
554                }
555            }
556        }
557        fourier.iter().flatten().map(|v| v * v).sum::<f64>() / n
558    }
559
560    /// Returns (physical energy/site, magnetization squared/site²) for diagnostics.
561    pub fn measure<S: Spin>(
562        &mut self,
563        lattice: &Lattice,
564        spins: &[S],
565        couplings: &[S::Value],
566        occupied: &[bool],
567        temperature: usize,
568    ) -> [f64; 2] {
569        self.measure_impl(lattice, spins, couplings, occupied, temperature, None)
570    }
571
572    /// Like [`Self::measure`], but takes energy and magnetization from `cached`
573    /// instead of repeating the bond pass. One-component collectors only; XY
574    /// helicity moments need the bond pass, so two-component collectors ignore it.
575    pub fn measure_cached<S: Spin>(
576        &mut self,
577        lattice: &Lattice,
578        spins: &[S],
579        couplings: &[S::Value],
580        occupied: &[bool],
581        temperature: usize,
582        cached: CachedTotals,
583    ) -> [f64; 2] {
584        debug_assert_eq!(self.components, 1);
585        self.measure_impl(
586            lattice,
587            spins,
588            couplings,
589            occupied,
590            temperature,
591            Some(cached),
592        )
593    }
594
595    fn measure_impl<S: Spin>(
596        &mut self,
597        lattice: &Lattice,
598        spins: &[S],
599        couplings: &[S::Value],
600        occupied: &[bool],
601        temperature: usize,
602        cached: Option<CachedTotals>,
603    ) -> [f64; 2] {
604        let n = lattice.n_spins as f64;
605        self.scratch.fill(0.0);
606        let i2_offset = 5 + 2 * lattice.n_dims;
607        let sk_offset = if self.components == 2 {
608            5 + 3 * lattice.n_dims
609        } else {
610            5
611        };
612        let (energy, magnetization) = match cached {
613            Some(totals) if self.components == 1 => (totals.energy, totals.magnetization),
614            _ => self.bond_pass(lattice, spins, couplings, occupied),
615        };
616        let m2 = dot(magnetization, magnetization) / (n * n);
617        self.scratch[..5].copy_from_slice(&[energy, energy * energy, m2.sqrt(), m2, m2 * m2]);
618        self.accumulate_planes(lattice, spins, occupied);
619        for d in 0..lattice.n_dims {
620            if self.components == 2 {
621                self.scratch[i2_offset + d] = self.scratch[5 + lattice.n_dims + d].powi(2);
622            }
623            self.scratch[sk_offset + d] = self.plane_structure_factor(d, n);
624        }
625        let correlation_offset = sk_offset + lattice.n_dims;
626        for (r, neighbors) in self.displaced.iter().enumerate() {
627            self.scratch[correlation_offset + r] = neighbors
628                .iter()
629                .enumerate()
630                .filter(|&(i, &j)| occupied[i] && occupied[j])
631                .map(|(i, &j)| dot(spins[i].components(), spins[j].components()))
632                .sum::<f64>()
633                / n;
634        }
635        if self.options.vortices {
636            let offset = correlation_offset + self.displaced.len();
637            let [density, intact] = self.vortices(lattice, spins, couplings, occupied);
638            self.scratch[offset] = density;
639            self.scratch[offset + 1] = intact;
640        }
641        for (dst, &value) in self.sums[temperature].iter_mut().zip(&self.scratch) {
642            *dst += value;
643        }
644        if self.options.block_size.is_some() {
645            for (dst, &value) in self.block_sums[temperature].iter_mut().zip(&self.scratch) {
646                *dst += value;
647            }
648        }
649        [energy, m2]
650    }
651
652    /// Energy per site and magnetization, plus the XY helicity sums in `scratch`.
653    fn bond_pass<S: Spin>(
654        &mut self,
655        lattice: &Lattice,
656        spins: &[S],
657        couplings: &[S::Value],
658        occupied: &[bool],
659    ) -> (f64, [f64; 2]) {
660        let d_offset = 5;
661        let i_offset = 5 + lattice.n_dims;
662        let mut magnetization = [0.0; 2];
663        let mut interaction = 0.0;
664        for i in 0..lattice.n_spins {
665            let spin = spins[i].components();
666            if occupied[i] {
667                magnetization[0] += spin[0];
668                magnetization[1] += spin[1];
669            }
670            for d in 0..lattice.n_neighbors {
671                let j = lattice.neighbor_fwd(i, d);
672                let coupling = couplings[i * lattice.n_neighbors + d].to_f64();
673                let neighbor = spins[j].components();
674                let cosine = coupling * dot(spin, neighbor);
675                interaction += cosine;
676                if self.components == 2 {
677                    self.scratch[d_offset + d] += cosine;
678                    self.scratch[i_offset + d] += coupling * cross(spin, neighbor);
679                }
680            }
681        }
682        (-interaction / lattice.n_spins as f64, magnetization)
683    }
684
685    /// Sums occupied spins over each hyperplane `x_d = x`. In row-major order a
686    /// period of `stride_d · L_d` sites holds `L_d` contiguous slabs of `stride_d`.
687    fn accumulate_planes<S: Spin>(&mut self, lattice: &Lattice, spins: &[S], occupied: &[bool]) {
688        for (d, plane) in self.planes.iter_mut().enumerate() {
689            plane.fill([0.0; 2]);
690            let stride = lattice.strides[d];
691            let period = stride * lattice.shape[d];
692            for (spin_period, mask_period) in spins
693                .chunks_exact(period)
694                .zip(occupied.chunks_exact(period))
695            {
696                if stride == 1 {
697                    for (sum, (spin, &occ)) in
698                        plane.iter_mut().zip(spin_period.iter().zip(mask_period))
699                    {
700                        let [a, b] = masked(*spin, occ);
701                        sum[0] += a;
702                        sum[1] += b;
703                    }
704                    continue;
705                }
706                let slabs = spin_period
707                    .chunks_exact(stride)
708                    .zip(mask_period.chunks_exact(stride));
709                for (sum, (slab, mask)) in plane.iter_mut().zip(slabs) {
710                    let [a, b] = masked_sum(slab, mask);
711                    sum[0] += a;
712                    sum[1] += b;
713                }
714            }
715        }
716    }
717
718    /// Geometric angle winding per intact plaquette, and intact plaquettes per site.
719    fn vortices<S: Spin>(
720        &mut self,
721        lattice: &Lattice,
722        spins: &[S],
723        couplings: &[S::Value],
724        occupied: &[bool],
725    ) -> [f64; 2] {
726        for (angle, spin) in self.angles.iter_mut().zip(spins) {
727            let [c, s] = spin.components();
728            *angle = s.atan2(c);
729        }
730        let angles = &self.angles;
731        // Angle from site a to site b, wrapped to [-π, π].
732        let edge = |a: usize, b: usize| {
733            let delta = angles[b] - angles[a];
734            delta - TAU * (delta / TAU).round()
735        };
736        let mut intact = 0;
737        let mut winding = 0.0;
738        for i in 0..lattice.n_spins {
739            let x = lattice.neighbor_fwd(i, 0);
740            let y = lattice.neighbor_fwd(i, 1);
741            let xy = lattice.neighbor_fwd(x, 1);
742            if ![i, x, xy, y].iter().all(|&j| occupied[j])
743                || [2 * i, 2 * i + 1, 2 * x + 1, 2 * y]
744                    .iter()
745                    .any(|&b| couplings[b].to_f64() == 0.0)
746            {
747                continue;
748            }
749            intact += 1;
750            let circulation = edge(i, x) + edge(x, xy) + edge(xy, y) + edge(y, i);
751            winding += (circulation / TAU).round().abs();
752        }
753        let density = if intact > 0 {
754            winding / intact as f64
755        } else {
756            f64::NAN
757        };
758        [density, intact as f64 / lattice.n_spins as f64]
759    }
760
761    pub fn end_sweep(&mut self, n_replicas: usize) {
762        self.count += n_replicas as u64;
763        if self.options.block_size.is_none() {
764            return;
765        }
766        self.block_count += n_replicas as u64;
767        self.block_sweeps += 1;
768        if self.options.block_size == Some(self.block_sweeps) {
769            self.flush_block();
770        }
771    }
772    /// Stores block rows only for temperatures where `owned` holds; the rest stay
773    /// empty. Collectors that each measure a subset of temperatures use this so
774    /// their blocks do not repeat everyone else's rows.
775    pub(crate) fn own_block_temperatures(&mut self, owned: impl Fn(usize) -> bool) {
776        self.block_temperatures = (0..self.sums.len()).map(owned).collect();
777    }
778
779    fn flush_block(&mut self) {
780        if self.block_count == 0 {
781            return;
782        }
783        let sums = self
784            .block_sums
785            .iter()
786            .enumerate()
787            .map(|(t, row)| {
788                let stored = self.block_temperatures.get(t).copied().unwrap_or(true);
789                if stored {
790                    row.clone()
791                } else {
792                    Vec::new()
793                }
794            })
795            .collect();
796        self.blocks.push(MomentBlock {
797            sums,
798            count: self.block_count,
799            sweeps: self.block_sweeps,
800        });
801        for row in &mut self.block_sums {
802            row.fill(0.0);
803        }
804        self.block_count = 0;
805        self.block_sweeps = 0;
806    }
807    /// Moments are NaN when no sweep was measured.
808    pub fn finish(mut self) -> PhysicsResult {
809        self.flush_block();
810        for value in self.sums.iter_mut().flatten() {
811            *value /= self.count as f64;
812        }
813        PhysicsResult {
814            fields: self.fields,
815            moments: self.sums,
816            count: self.count,
817            blocks: self.blocks,
818        }
819    }
820}
821
822#[inline]
823fn masked<S: Spin>(spin: S, occupied: bool) -> [f64; 2] {
824    let weight = f64::from(u8::from(occupied));
825    let [a, b] = spin.components();
826    [weight * a, weight * b]
827}
828
829/// Sum of occupied spin components, with four accumulators to break the
830/// floating-point add dependency chain.
831fn masked_sum<S: Spin>(slab: &[S], mask: &[bool]) -> [f64; 2] {
832    let mut lanes = [[0.0; 2]; 4];
833    let (spins4, spin_tail) = slab.as_chunks::<4>();
834    let (masks4, mask_tail) = mask.as_chunks::<4>();
835    let mut tail = [0.0; 2];
836    for (&spin, &occ) in spin_tail.iter().zip(mask_tail) {
837        let [a, b] = masked(spin, occ);
838        tail[0] += a;
839        tail[1] += b;
840    }
841    for (spins, masks) in spins4.iter().zip(masks4) {
842        for ((lane, &spin), &occ) in lanes.iter_mut().zip(spins).zip(masks) {
843            let [a, b] = masked(spin, occ);
844            lane[0] += a;
845            lane[1] += b;
846        }
847    }
848    let [l0, l1, l2, l3] = lanes;
849    [
850        (l0[0] + l1[0]) + (l2[0] + l3[0]) + tail[0],
851        (l0[1] + l1[1]) + (l2[1] + l3[1]) + tail[1],
852    ]
853}
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858    use rand::{Rng, SeedableRng};
859    use rand_xoshiro::Xoshiro256StarStar;
860    #[test]
861    fn signed_winding_mask_coverage_and_rotation_invariance() {
862        let lattice = Lattice::new(vec![3, 3]);
863        let mut spins = vec![[1.0, 0.0]; 9];
864        spins[3] = [0.0, 1.0];
865        spins[4] = [-1.0, 0.0];
866        spins[1] = [0.0, -1.0];
867        let mut j = vec![0.0; 18];
868        for (bond, value) in [(0, 1.0), (7, -1.0), (2, 1.0), (1, 1.0)] {
869            j[bond] = value;
870        }
871        let mut occupied = vec![false; 9];
872        for i in [0, 3, 4, 1, 8] {
873            occupied[i] = true;
874        }
875        let options = PhysicsOptions {
876            vortices: true,
877            displacements: vec![vec![1, 0], vec![0, 0]],
878            block_size: Some(2),
879        };
880        let measure = |s: &[[f64; 2]], j: &[f64]| {
881            let mut collector = PhysicsCollector::new(&lattice, 1, &options, 2).unwrap();
882            collector.measure(&lattice, s, j, &occupied, 0);
883            collector.end_sweep(1);
884            collector.finish().values(&lattice.shape, &[1.0], 2)
885        };
886        let original = measure(&spins, &j);
887        assert_eq!(original["angle_vortex_density"][0][0], 1.0);
888        assert_eq!(original["intact_plaquette_fraction"][0][0], 1.0 / 9.0);
889        assert_eq!(original["correlations"][0][1], 5.0 / 9.0);
890        for s in &mut spins {
891            *s = [0.6 * s[0] - 0.8 * s[1], 0.8 * s[0] + 0.6 * s[1]];
892        }
893        let rotated = measure(&spins, &j);
894        for (name, rows) in &original {
895            for (a, b) in rows.iter().flatten().zip(rotated[name].iter().flatten()) {
896                assert!(
897                    (a - b).abs() < 1e-12 || (a.is_nan() && b.is_nan()),
898                    "{name}: {a} vs {b}"
899                );
900            }
901        }
902        j[0] = 0.0;
903        let broken = measure(&spins, &j);
904        assert!(broken["angle_vortex_density"][0][0].is_nan());
905        assert_eq!(broken["intact_plaquette_fraction"][0][0], 0.0);
906    }
907    #[test]
908    fn disorder_variance_is_excluded_from_heat_capacity() {
909        let lattice = Lattice::new(vec![3, 3]);
910        let spins = vec![[1.0, 0.0]; 9];
911        let occupied = vec![true; 9];
912        let results: Vec<_> = [1.0, 2.0]
913            .iter()
914            .map(|&j| {
915                let mut c =
916                    PhysicsCollector::new(&lattice, 1, &PhysicsOptions::default(), 2).unwrap();
917                for _ in 0..3 {
918                    c.measure(&lattice, &spins, &[j; 18], &occupied, 0);
919                    c.end_sweep(1);
920                }
921                c.finish()
922            })
923            .collect();
924        let aggregate = PhysicsResult::aggregate(&results, &[3, 3], &[1.0], 2);
925        assert_eq!(aggregate["energies"][0][0], -3.0);
926        assert_eq!(aggregate["energies2"][0][0], 10.0);
927        assert_eq!(aggregate["heat_capacity"][0][0], 0.0);
928        assert_eq!(aggregate["binder_cumulant"][0][0], 0.5);
929    }
930    fn random_xy(n: usize, seed: u64) -> Vec<[f64; 2]> {
931        let mut rng = Xoshiro256StarStar::seed_from_u64(seed);
932        (0..n).map(|_| <[f64; 2]>::random(&mut rng)).collect()
933    }
934
935    /// Direct per-site Fourier sum Σ_d |Σ_i s_i e^{2πi x_d/L_d}|² / N.
936    fn direct_structure_factor(
937        lattice: &Lattice,
938        spins: &[[f64; 2]],
939        occupied: &[bool],
940    ) -> Vec<f64> {
941        (0..lattice.n_dims)
942            .map(|d| {
943                let mut f = [[0.0; 2]; 2];
944                for (i, spin) in spins.iter().enumerate().filter(|&(i, _)| occupied[i]) {
945                    let x = (i / lattice.strides[d]) % lattice.shape[d];
946                    let (sin, cos) = (TAU * x as f64 / lattice.shape[d] as f64).sin_cos();
947                    for k in 0..2 {
948                        f[k][0] += spin[k] * cos;
949                        f[k][1] += spin[k] * sin;
950                    }
951                }
952                f.iter().flatten().map(|v| v * v).sum::<f64>() / lattice.n_spins as f64
953            })
954            .collect()
955    }
956
957    #[test]
958    fn plane_sums_match_direct_fourier_sums() {
959        let lattice = Lattice::new(vec![3, 4, 5]);
960        let spins = random_xy(lattice.n_spins, 3);
961        let occupied: Vec<bool> = (0..lattice.n_spins).map(|i| i % 7 != 3).collect();
962        let mut c = PhysicsCollector::new(&lattice, 1, &PhysicsOptions::default(), 2).unwrap();
963        c.measure(
964            &lattice,
965            &spins,
966            &vec![0.0; 3 * lattice.n_spins],
967            &occupied,
968            0,
969        );
970        c.end_sweep(1);
971        let got = &c.finish().values(&lattice.shape, &[1.0], 2)["structure_factor_min"][0];
972        for (a, b) in got
973            .iter()
974            .zip(direct_structure_factor(&lattice, &spins, &occupied))
975        {
976            assert!((a - b).abs() < 1e-12, "{a} vs {b}");
977        }
978    }
979
980    #[test]
981    fn angle_differences_match_per_edge_atan2_winding() {
982        let lattice = Lattice::new(vec![6, 5]);
983        let couplings = vec![1.0; 2 * lattice.n_spins];
984        let occupied = vec![true; lattice.n_spins];
985        let options = PhysicsOptions {
986            vortices: true,
987            ..Default::default()
988        };
989        for seed in 0..8 {
990            let spins = random_xy(lattice.n_spins, seed);
991            let mut c = PhysicsCollector::new(&lattice, 1, &options, 2).unwrap();
992            c.measure(&lattice, &spins, &couplings, &occupied, 0);
993            c.end_sweep(1);
994            let got = c.finish().values(&lattice.shape, &[1.0], 2)["angle_vortex_density"][0][0];
995            let vortices: f64 = (0..lattice.n_spins)
996                .map(|i| {
997                    let x = lattice.neighbor_fwd(i, 0);
998                    let y = lattice.neighbor_fwd(i, 1);
999                    let xy = lattice.neighbor_fwd(x, 1);
1000                    let circulation: f64 = [i, x, xy, y, i]
1001                        .windows(2)
1002                        .map(|e| {
1003                            let (a, b) = (spins[e[0]], spins[e[1]]);
1004                            cross(a, b).atan2(dot(a, b))
1005                        })
1006                        .sum();
1007                    (circulation / TAU).round().abs()
1008                })
1009                .sum();
1010            assert_eq!(got, vortices / lattice.n_spins as f64, "seed {seed}");
1011        }
1012    }
1013
1014    #[test]
1015    fn cached_totals_reproduce_the_bond_pass() {
1016        // N = 32 keeps the f32 energy cache exact for ±J couplings.
1017        let lattice = Lattice::new(vec![4, 8]);
1018        let mut rng = Xoshiro256StarStar::seed_from_u64(17);
1019        let spins: Vec<i8> = (0..lattice.n_spins).map(|_| i8::random(&mut rng)).collect();
1020        let couplings: Vec<f32> = (0..2 * lattice.n_spins)
1021            .map(|_| if rng.gen::<bool>() { 1.0 } else { -1.0 })
1022            .collect();
1023        let occupied = vec![true; lattice.n_spins];
1024        let options = PhysicsOptions {
1025            displacements: vec![vec![1, 2]],
1026            ..Default::default()
1027        };
1028        let mut energies = [0.0f32];
1029        let mut magnetization = [0i64];
1030        crate::spins::energy::compute_energies_and_magnetizations_into(
1031            &lattice,
1032            &spins,
1033            &couplings,
1034            &mut energies,
1035            &mut magnetization,
1036        );
1037        let mut full = PhysicsCollector::new(&lattice, 1, &options, 1).unwrap();
1038        let mut cached = PhysicsCollector::new(&lattice, 1, &options, 1).unwrap();
1039        let a = full.measure(&lattice, &spins, &couplings, &occupied, 0);
1040        let b = cached.measure_cached(
1041            &lattice,
1042            &spins,
1043            &couplings,
1044            &occupied,
1045            0,
1046            CachedTotals {
1047                energy: -(energies[0] as f64),
1048                magnetization: [magnetization[0] as f64, 0.0],
1049            },
1050        );
1051        assert_eq!(a, b);
1052        assert_eq!(full.sums, cached.sums);
1053    }
1054
1055    #[test]
1056    fn overlap_moments_match_direct_sums_per_pair() {
1057        let lattice = Lattice::new(vec![3, 4, 5]);
1058        let mut rng = Xoshiro256StarStar::seed_from_u64(5);
1059        let mut ising = || -> Vec<i8> {
1060            (0..lattice.n_spins)
1061                .map(|_| if rng.gen::<bool>() { 1 } else { -1 })
1062                .collect()
1063        };
1064        let (a, b) = (ising(), ising());
1065        let occupied = vec![true; lattice.n_spins];
1066        let mut c = PhysicsCollector::new(&lattice, 1, &PhysicsOptions::default(), 1)
1067            .unwrap()
1068            .with_overlap(&lattice);
1069        // One pair of two replicas: weight 2 against a replica count of 2.
1070        c.measure_overlap(&lattice, &a, &b, &occupied, 0, 2.0);
1071        c.end_sweep(2);
1072        let values = c.finish().values(&lattice.shape, &[1.0], 1);
1073
1074        let q: Vec<[f64; 2]> = a
1075            .iter()
1076            .zip(&b)
1077            .map(|(&x, &y)| [f64::from(x * y), 0.0])
1078            .collect();
1079        let mean = q.iter().map(|v| v[0]).sum::<f64>() / lattice.n_spins as f64;
1080        assert!((values["overlap2"][0][0] - mean.powi(2)).abs() < 1e-12);
1081        assert!((values["overlap4"][0][0] - mean.powi(4)).abs() < 1e-12);
1082        for (got, want) in values["overlap_structure_factor_min"][0]
1083            .iter()
1084            .zip(direct_structure_factor(&lattice, &q, &occupied))
1085        {
1086            assert!((got - want).abs() < 1e-12, "{got} vs {want}");
1087        }
1088    }
1089
1090    #[test]
1091    fn overlap2_all_pairs_averages_every_replica_pair() {
1092        let lattice = Lattice::new(vec![4, 5]);
1093        let mut rng = Xoshiro256StarStar::seed_from_u64(8);
1094        let replicas: Vec<Vec<i8>> = (0..3)
1095            .map(|_| {
1096                (0..lattice.n_spins)
1097                    .map(|_| if rng.gen::<bool>() { 1 } else { -1 })
1098                    .collect()
1099            })
1100            .collect();
1101        let q2 = |a: &[i8], b: &[i8]| {
1102            let dot: i64 = a.iter().zip(b).map(|(&x, &y)| i64::from(x * y)).sum();
1103            (dot as f64 / lattice.n_spins as f64).powi(2)
1104        };
1105        let expected = (q2(&replicas[0], &replicas[1])
1106            + q2(&replicas[0], &replicas[2])
1107            + q2(&replicas[1], &replicas[2]))
1108            / 3.0;
1109        let views: Vec<&[i8]> = replicas.iter().map(Vec::as_slice).collect();
1110        let mut c = PhysicsCollector::new(&lattice, 1, &PhysicsOptions::default(), 1)
1111            .unwrap()
1112            .with_overlap(&lattice);
1113        c.measure_overlap_all_pairs(&views, 0, 3.0);
1114        c.end_sweep(3);
1115        let values = c.finish().values(&lattice.shape, &[1.0], 1);
1116        assert!((values["overlap2_all_pairs"][0][0] - expected).abs() < 1e-12);
1117        // The fixed-pair moments are untouched by the all-pairs measurement.
1118        assert_eq!(values["overlap2"][0][0], 0.0);
1119    }
1120
1121    /// An unmeasured moment is undefined; zero would read as a measured value.
1122    /// For a plain disorder mean the jackknife error is the standard error of the
1123    /// mean; for a ratio it differs from the spread of per-sample ratios.
1124    #[test]
1125    fn jackknife_of_a_mean_is_the_standard_error() {
1126        let lattice = Lattice::new(vec![3, 3]);
1127        let sample = |scale: f64| {
1128            let collector =
1129                PhysicsCollector::new(&lattice, 1, &PhysicsOptions::default(), 1).unwrap();
1130            let mut result = collector.finish();
1131            result.count = 1;
1132            for (k, moment) in result.moments[0].iter_mut().enumerate() {
1133                *moment = scale * (1.0 + k as f64);
1134            }
1135            result
1136        };
1137        let samples = [sample(1.0), sample(2.0), sample(4.0)];
1138        let stderr = PhysicsResult::jackknife_stderr(&samples, &[3, 3], &[1.5], 1);
1139        // mags2 values 2, 4, 8 per the field order: sample standard deviation / sqrt(3).
1140        let values: Vec<f64> = samples
1141            .iter()
1142            .map(|s| s.values(&[3, 3], &[1.5], 1)["mags2"][0][0])
1143            .collect();
1144        let mean = values.iter().sum::<f64>() / 3.0;
1145        let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / 2.0;
1146        assert!((stderr["mags2"][0][0] - (variance / 3.0).sqrt()).abs() < 1e-12);
1147        assert!(stderr["binder_cumulant"][0][0].is_finite());
1148
1149        let single = PhysicsResult::jackknife_stderr(&samples[..1], &[3, 3], &[1.5], 1);
1150        assert!(single["mags2"][0][0].is_nan());
1151    }
1152
1153    #[test]
1154    fn finish_without_measurements_is_nan() {
1155        let lattice = Lattice::new(vec![3, 3]);
1156        let c = PhysicsCollector::new(&lattice, 2, &PhysicsOptions::default(), 1).unwrap();
1157        let result = c.finish();
1158        assert_eq!(result.count, 0);
1159        assert!(result.moments.iter().flatten().all(|v| v.is_nan()));
1160    }
1161
1162    #[test]
1163    fn antiferromagnetic_uniform_length_is_undefined_without_clamping() {
1164        let lattice = Lattice::new(vec![4, 4]);
1165        let spins: Vec<_> = (0..16)
1166            .map(|i| {
1167                if (i / 4 + i % 4) % 2 == 0 {
1168                    [1.0, 0.0]
1169                } else {
1170                    [-1.0, 0.0]
1171                }
1172            })
1173            .collect();
1174        let mut c = PhysicsCollector::new(
1175            &lattice,
1176            1,
1177            &PhysicsOptions {
1178                displacements: vec![vec![1, 0]],
1179                ..Default::default()
1180            },
1181            2,
1182        )
1183        .unwrap();
1184        c.measure(&lattice, &spins, &vec![-1.0; 32], &[true; 16], 0);
1185        c.end_sweep(1);
1186        let result = c.finish().values(&[4, 4], &[1.0], 2);
1187        assert_eq!(result["correlations"][0][0], -1.0);
1188        assert!(result["correlation_length"][0].iter().all(|v| v.is_nan()));
1189    }
1190}