Skip to main content

spin_sim/statistics/
overlap.rs

1use crate::geometry::Lattice;
2
3use super::Statistics;
4
5/// Overlap statistics from replica-pair measurements.
6///
7/// All vectors are indexed by temperature. Empty when `n_replicas < 2`.
8pub struct OverlapStats {
9    /// ⟨q⟩ — mean replica overlap.
10    pub overlap: Vec<f64>,
11    /// ⟨q²⟩.
12    pub overlap2: Vec<f64>,
13    /// ⟨q⁴⟩.
14    pub overlap4: Vec<f64>,
15    /// ⟨q_l⟩ — mean link overlap.
16    pub link_overlap: Vec<f64>,
17    /// ⟨q_l²⟩.
18    pub link_overlap2: Vec<f64>,
19    /// ⟨q_l⁴⟩.
20    pub link_overlap4: Vec<f64>,
21    /// Overlap histogram P(q) per temperature: `[n_temps][n_spins + 1]`.
22    /// Bins correspond to dot-product values −N, −N+2, …, N where `idx = (dot + N) / 2`.
23    pub histogram: Vec<Vec<u64>>,
24    /// Conditional sum of q_l at each q bin: `[n_temps][n_spins + 1]`.
25    /// Divide by `histogram` counts to get ⟨q_l | q⟩.
26    pub ql_at_q_sum: Vec<Vec<f64>>,
27    /// Conditional sum of q_l² at each q bin: `[n_temps][n_spins + 1]`.
28    /// Use with `ql_at_q_sum` and `histogram` to compute A(q) = Var(q_l | q).
29    pub ql2_at_q_sum: Vec<Vec<f64>>,
30    /// Per-disorder-sample overlap histograms: `[n_disorder][n_temps][n_spins + 1]`.
31    /// Only populated by `aggregate()`; empty for single-realization results.
32    pub per_sample_histogram: Vec<Vec<Vec<u64>>>,
33    /// Per-disorder-sample conditional sums: `[n_disorder][n_temps][n_spins + 1]`.
34    /// Only populated by `aggregate()`; empty for single-realization results.
35    pub per_sample_ql_at_q_sum: Vec<Vec<Vec<f64>>>,
36    /// Per-disorder-sample conditional sums of squares: `[n_disorder][n_temps][n_spins + 1]`.
37    /// Only populated by `aggregate()`; empty for single-realization results.
38    pub per_sample_ql2_at_q_sum: Vec<Vec<Vec<f64>>>,
39}
40
41impl OverlapStats {
42    pub fn empty() -> Self {
43        Self {
44            overlap: vec![],
45            overlap2: vec![],
46            overlap4: vec![],
47            link_overlap: vec![],
48            link_overlap2: vec![],
49            link_overlap4: vec![],
50            histogram: vec![],
51            ql_at_q_sum: vec![],
52            ql2_at_q_sum: vec![],
53            per_sample_histogram: vec![],
54            per_sample_ql_at_q_sum: vec![],
55            per_sample_ql2_at_q_sum: vec![],
56        }
57    }
58
59    /// Average across disorder realizations; an empty slice yields [`OverlapStats::empty`].
60    pub fn aggregate(results: &[&Self]) -> Self {
61        Self::aggregate_impl(results, true)
62    }
63
64    pub(crate) fn aggregate_without_samples(results: &[&Self]) -> Self {
65        Self::aggregate_impl(results, false)
66    }
67
68    fn aggregate_impl(results: &[&Self], retain_samples: bool) -> Self {
69        if results.first().is_none_or(|r| r.overlap.is_empty()) {
70            return Self::empty();
71        }
72
73        let n = results.len() as f64;
74        let n_temps = results[0].overlap.len();
75        let n_hist = results[0].histogram.len();
76        let hist_bins = results[0].histogram[0].len();
77        let n_ql = results[0].ql_at_q_sum.len();
78        let ql_bins = results[0].ql_at_q_sum.first().map_or(0, |v| v.len());
79
80        let mut agg = Self {
81            overlap: vec![0.0; n_temps],
82            overlap2: vec![0.0; n_temps],
83            overlap4: vec![0.0; n_temps],
84            link_overlap: vec![0.0; n_temps],
85            link_overlap2: vec![0.0; n_temps],
86            link_overlap4: vec![0.0; n_temps],
87            histogram: (0..n_hist).map(|_| vec![0u64; hist_bins]).collect(),
88            ql_at_q_sum: (0..n_ql).map(|_| vec![0.0; ql_bins]).collect(),
89            ql2_at_q_sum: (0..n_ql).map(|_| vec![0.0; ql_bins]).collect(),
90            per_sample_histogram: if retain_samples {
91                results.iter().map(|r| r.histogram.clone()).collect()
92            } else {
93                vec![]
94            },
95            per_sample_ql_at_q_sum: if retain_samples {
96                results.iter().map(|r| r.ql_at_q_sum.clone()).collect()
97            } else {
98                vec![]
99            },
100            per_sample_ql2_at_q_sum: if retain_samples {
101                results.iter().map(|r| r.ql2_at_q_sum.clone()).collect()
102            } else {
103                vec![]
104            },
105        };
106
107        for r in results {
108            for (a, &v) in agg.overlap.iter_mut().zip(r.overlap.iter()) {
109                *a += v;
110            }
111            for (a, &v) in agg.overlap2.iter_mut().zip(r.overlap2.iter()) {
112                *a += v;
113            }
114            for (a, &v) in agg.overlap4.iter_mut().zip(r.overlap4.iter()) {
115                *a += v;
116            }
117            for (a, &v) in agg.link_overlap.iter_mut().zip(r.link_overlap.iter()) {
118                *a += v;
119            }
120            for (a, &v) in agg.link_overlap2.iter_mut().zip(r.link_overlap2.iter()) {
121                *a += v;
122            }
123            for (a, &v) in agg.link_overlap4.iter_mut().zip(r.link_overlap4.iter()) {
124                *a += v;
125            }
126            for (a, s) in agg.histogram.iter_mut().zip(r.histogram.iter()) {
127                for (ah, &sh) in a.iter_mut().zip(s.iter()) {
128                    *ah += sh;
129                }
130            }
131            for (a, s) in agg.ql_at_q_sum.iter_mut().zip(r.ql_at_q_sum.iter()) {
132                for (ah, &sh) in a.iter_mut().zip(s.iter()) {
133                    *ah += sh;
134                }
135            }
136            for (a, s) in agg.ql2_at_q_sum.iter_mut().zip(r.ql2_at_q_sum.iter()) {
137                for (ah, &sh) in a.iter_mut().zip(s.iter()) {
138                    *ah += sh;
139                }
140            }
141        }
142
143        for v in agg
144            .overlap
145            .iter_mut()
146            .chain(agg.overlap2.iter_mut())
147            .chain(agg.overlap4.iter_mut())
148            .chain(agg.link_overlap.iter_mut())
149            .chain(agg.link_overlap2.iter_mut())
150            .chain(agg.link_overlap4.iter_mut())
151        {
152            *v /= n;
153        }
154
155        agg
156    }
157}
158
159/// Accumulator for overlap measurements during the sweep loop.
160pub struct OverlapAccum {
161    n_pairs: usize,
162    /// Replicas entering the all-pairs q² average; odd counts have one unpaired.
163    n_replicas: usize,
164    n_temps: usize,
165    n_spins: usize,
166    n_bonds: usize,
167    equil_diag: bool,
168    collect_q2_ac: bool,
169
170    overlap_stat: Statistics,
171    overlap2_stat: Statistics,
172    overlap4_stat: Statistics,
173    link_overlap_stat: Statistics,
174    link_overlap2_stat: Statistics,
175    link_overlap4_stat: Statistics,
176
177    histogram: Vec<Vec<u64>>,
178    ql_at_q_sum: Vec<Vec<f64>>,
179    ql2_at_q_sum: Vec<Vec<f64>>,
180
181    overlaps_buf: Vec<f32>,
182    overlaps2_buf: Vec<f32>,
183    overlaps4_buf: Vec<f32>,
184    link_overlaps_buf: Vec<f32>,
185    link_overlaps2_buf: Vec<f32>,
186    link_overlaps4_buf: Vec<f32>,
187
188    pub diag_ql_buf: Vec<f32>,
189    /// Pair-averaged q² of the current sweep, for the equilibration diagnostic.
190    pub diag_q2_buf: Vec<f32>,
191    pub q2_ac_buf: Vec<f64>,
192    site_overlap: Vec<i8>,
193}
194
195/// Sum of `q²` over every pair among `n_replicas` equal-length configurations, with
196/// the number of pairs. Unlike fixed pairs, the sum ignores how replicas are labeled.
197pub(crate) fn pair_q2_sum<'a>(
198    n_replicas: usize,
199    replica: impl Fn(usize) -> &'a [i8],
200) -> (f64, usize) {
201    let mut sum = 0.0;
202    let mut pairs = 0;
203    for a in 0..n_replicas {
204        let spins_a = replica(a);
205        let n = spins_a.len() as f64;
206        for b in a + 1..n_replicas {
207            let dot: i64 = spins_a
208                .iter()
209                .zip(replica(b))
210                .map(|(&x, &y)| i64::from(x * y))
211                .sum();
212            let q = dot as f64 / n;
213            sum += q * q;
214            pairs += 1;
215        }
216    }
217    (sum, pairs)
218}
219
220/// Returns `(Σ_i q_i, Σ_i Σ_d q_i q_{fwd(i,d)})` for site overlaps `q_i = ±1`.
221///
222/// Canonical square and cubic lattices use contiguous shifted products; other
223/// lattices walk the neighbor table. Both give the same integers.
224fn overlap_sums(lattice: &Lattice, q: &[i8]) -> (i64, i64) {
225    if let Some((l0, l1)) = lattice.square_shape() {
226        return overlap_sums_strided(&[l0, l1], q);
227    }
228    if let Some(shape) = lattice.cubic_shape() {
229        return overlap_sums_strided(&shape, q);
230    }
231    overlap_sums_chunked(lattice, q, i32::MAX as usize / (lattice.n_neighbors + 1))
232}
233
234/// Exact dot product of two ±1 vectors.
235fn dot_i8(a: &[i8], b: &[i8]) -> i64 {
236    // Short chunks keep each i32 partial sum far from overflow and vectorizable.
237    const CHUNK: usize = 1 << 15;
238    a.chunks(CHUNK)
239        .zip(b.chunks(CHUNK))
240        .map(|(a, b)| {
241            i64::from(
242                a.iter()
243                    .zip(b)
244                    .map(|(&x, &y)| i32::from(x * y))
245                    .sum::<i32>(),
246            )
247        })
248        .sum()
249}
250
251/// [`overlap_sums`] on a row-major hypercubic lattice with the given extents.
252///
253/// Along a direction of stride `s` and extent `L` the lattice splits into blocks of
254/// `s * L` sites; within a block site `j` pairs with `j + s`, and the last `s` sites
255/// wrap to the first `s`.
256fn overlap_sums_strided(extents: &[usize], q: &[i8]) -> (i64, i64) {
257    if q.is_empty() {
258        return (0, 0);
259    }
260    let dot_spin = q
261        .chunks(1 << 15)
262        .map(|chunk| i64::from(chunk.iter().map(|&v| i32::from(v)).sum::<i32>()))
263        .sum();
264    let mut dot_link = 0i64;
265    let mut stride = q.len();
266    for &extent in extents {
267        let period = stride;
268        stride /= extent;
269        for block in q.chunks_exact(period) {
270            dot_link += dot_i8(&block[..period - stride], &block[stride..]);
271            dot_link += dot_i8(&block[period - stride..], &block[..stride]);
272        }
273    }
274    (dot_spin, dot_link)
275}
276
277fn overlap_sums_chunked(lattice: &Lattice, q: &[i8], chunk: usize) -> (i64, i64) {
278    let n_neighbors = lattice.n_neighbors;
279    let chunk = chunk.max(1);
280    let mut dot_spin = 0i64;
281    let mut dot_link = 0i64;
282    for start in (0..q.len()).step_by(chunk) {
283        let mut spin_acc = 0i32;
284        let mut link_acc = 0i32;
285        for j in start..q.len().min(start + chunk) {
286            let qj = q[j] as i32;
287            let mut neighbors = 0i32;
288            for d in 0..n_neighbors {
289                neighbors += q[lattice.neighbor_fwd(j, d)] as i32;
290            }
291            spin_acc += qj;
292            link_acc += qj * neighbors;
293        }
294        dot_spin += spin_acc as i64;
295        dot_link += link_acc as i64;
296    }
297    (dot_spin, dot_link)
298}
299
300impl OverlapAccum {
301    pub fn new(
302        n_temps: usize,
303        n_spins: usize,
304        n_pairs: usize,
305        n_neighbors: usize,
306        equil_diag: bool,
307        collect_q2_ac: bool,
308    ) -> Self {
309        let has_pairs = n_pairs > 0;
310        Self {
311            n_pairs,
312            n_replicas: 2 * n_pairs,
313            n_temps,
314            n_spins,
315            n_bonds: n_spins * n_neighbors,
316            equil_diag,
317            collect_q2_ac,
318
319            overlap_stat: Statistics::new(n_temps, 1),
320            overlap2_stat: Statistics::new(n_temps, 1),
321            overlap4_stat: Statistics::new(n_temps, 1),
322            link_overlap_stat: Statistics::new(n_temps, 1),
323            link_overlap2_stat: Statistics::new(n_temps, 1),
324            link_overlap4_stat: Statistics::new(n_temps, 1),
325
326            histogram: if has_pairs {
327                (0..n_temps).map(|_| vec![0u64; n_spins + 1]).collect()
328            } else {
329                vec![]
330            },
331            ql_at_q_sum: if has_pairs {
332                (0..n_temps).map(|_| vec![0.0f64; n_spins + 1]).collect()
333            } else {
334                vec![]
335            },
336            ql2_at_q_sum: if has_pairs {
337                (0..n_temps).map(|_| vec![0.0f64; n_spins + 1]).collect()
338            } else {
339                vec![]
340            },
341
342            overlaps_buf: vec![0.0f32; n_temps],
343            overlaps2_buf: vec![0.0f32; n_temps],
344            overlaps4_buf: vec![0.0f32; n_temps],
345            link_overlaps_buf: vec![0.0f32; n_temps],
346            link_overlaps2_buf: vec![0.0f32; n_temps],
347            link_overlaps4_buf: vec![0.0f32; n_temps],
348
349            diag_ql_buf: vec![0.0; if equil_diag { n_temps } else { 0 }],
350            diag_q2_buf: vec![0.0; if equil_diag { n_temps } else { 0 }],
351            q2_ac_buf: if collect_q2_ac {
352                vec![0.0f64; n_temps]
353            } else {
354                vec![]
355            },
356            site_overlap: if has_pairs { vec![0; n_spins] } else { vec![] },
357        }
358    }
359
360    /// Drops the link-overlap sums conditioned on the overlap bin, which are two of
361    /// the three histogram-sized arrays.
362    pub fn without_conditional_link_overlap(mut self) -> Self {
363        self.ql_at_q_sum = vec![];
364        self.ql2_at_q_sum = vec![];
365        self
366    }
367
368    /// Sets the replica count for the all-pairs q² average, which also covers the
369    /// unpaired replica of an odd count.
370    pub fn with_replicas(mut self, n_replicas: usize) -> Self {
371        self.n_replicas = n_replicas;
372        self
373    }
374
375    #[inline]
376    pub fn collect(&mut self, lattice: &Lattice, spins: &[i8], system_ids: &[usize], record: bool) {
377        if self.equil_diag {
378            self.diag_ql_buf.fill(0.0);
379            self.diag_q2_buf.fill(0.0);
380        }
381        for pair_idx in 0..self.n_pairs {
382            let r_a = 2 * pair_idx;
383            let r_b = 2 * pair_idx + 1;
384
385            for t in 0..self.n_temps {
386                let sys_a = system_ids[r_a * self.n_temps + t];
387                let sys_b = system_ids[r_b * self.n_temps + t];
388                let spins_a = &spins[sys_a * self.n_spins..(sys_a + 1) * self.n_spins];
389                let spins_b = &spins[sys_b * self.n_spins..(sys_b + 1) * self.n_spins];
390                for ((q, &sa), &sb) in self.site_overlap.iter_mut().zip(spins_a).zip(spins_b) {
391                    *q = sa * sb;
392                }
393                let (dot_spin, dot_link) = overlap_sums(lattice, &self.site_overlap);
394
395                let ql = dot_link as f32 / self.n_bonds as f32;
396
397                let q = dot_spin as f32 / self.n_spins as f32;
398                let q2 = q * q;
399                if self.equil_diag {
400                    self.diag_ql_buf[t] += ql;
401                    self.diag_q2_buf[t] += q2;
402                }
403                if !record {
404                    continue;
405                }
406
407                self.overlaps_buf[t] = q;
408                self.overlaps2_buf[t] = q2;
409                self.overlaps4_buf[t] = q2 * q2;
410
411                let ql2 = ql * ql;
412                self.link_overlaps_buf[t] = ql;
413                self.link_overlaps2_buf[t] = ql2;
414                self.link_overlaps4_buf[t] = ql2 * ql2;
415
416                let idx = ((dot_spin + self.n_spins as i64) / 2) as usize;
417                self.histogram[t][idx] += 1;
418                if let Some(sum) = self.ql_at_q_sum.get_mut(t) {
419                    sum[idx] += ql as f64;
420                    self.ql2_at_q_sum[t][idx] += (ql * ql) as f64;
421                }
422            }
423
424            if !record {
425                continue;
426            }
427
428            self.overlap_stat.update(&self.overlaps_buf);
429            self.overlap2_stat.update(&self.overlaps2_buf);
430            self.overlap4_stat.update(&self.overlaps4_buf);
431            self.link_overlap_stat.update(&self.link_overlaps_buf);
432            self.link_overlap2_stat.update(&self.link_overlaps2_buf);
433            self.link_overlap4_stat.update(&self.link_overlaps4_buf);
434        }
435
436        if self.equil_diag {
437            let inv = 1.0 / self.n_pairs as f32;
438            for v in self.diag_ql_buf.iter_mut().chain(&mut self.diag_q2_buf) {
439                *v *= inv;
440            }
441        }
442        if self.collect_q2_ac && record {
443            self.average_q2_over_all_pairs(spins, system_ids);
444        }
445    }
446
447    /// Mean q^2 over every replica pair at each temperature, for the autocorrelation.
448    ///
449    /// Fixed pairs (2p, 2p + 1) would let moves that merely relabel replicas at one
450    /// temperature (e.g. a spanning Houdayer cluster) look like decorrelation; the
451    /// all-pairs mean is invariant under such relabelings.
452    fn average_q2_over_all_pairs(&mut self, spins: &[i8], system_ids: &[usize]) {
453        let n = self.n_spins;
454        for t in 0..self.n_temps {
455            let (sum, pairs) = pair_q2_sum(self.n_replicas, |replica| {
456                let system = system_ids[replica * self.n_temps + t];
457                &spins[system * n..(system + 1) * n]
458            });
459            self.q2_ac_buf[t] = sum / pairs as f64;
460        }
461    }
462
463    pub fn finish(self) -> OverlapStats {
464        if self.n_pairs == 0 {
465            return OverlapStats::empty();
466        }
467        OverlapStats {
468            overlap: self.overlap_stat.average(),
469            overlap2: self.overlap2_stat.average(),
470            overlap4: self.overlap4_stat.average(),
471            link_overlap: self.link_overlap_stat.average(),
472            link_overlap2: self.link_overlap2_stat.average(),
473            link_overlap4: self.link_overlap4_stat.average(),
474            histogram: self.histogram,
475            ql_at_q_sum: self.ql_at_q_sum,
476            ql2_at_q_sum: self.ql2_at_q_sum,
477            per_sample_histogram: vec![],
478            per_sample_ql_at_q_sum: vec![],
479            per_sample_ql2_at_q_sum: vec![],
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486
487    /// The autocorrelation observable must not change when replicas at one temperature
488    /// swap labels, and must reduce to the single pair's q^2 for two replicas.
489    #[test]
490    fn q2_autocorrelation_observable_is_relabel_invariant() {
491        let lattice = Lattice::new(vec![4, 4]);
492        let n = lattice.n_spins;
493        let n_temps = 2;
494        let spins: Vec<i8> = (0..4 * n_temps * n)
495            .map(|k| if (k * 37 + k / 5) % 7 < 3 { -1 } else { 1 })
496            .collect();
497        let observable = |system_ids: &[usize], n_pairs: usize| {
498            let mut accum =
499                OverlapAccum::new(n_temps, n, n_pairs, lattice.n_neighbors, false, true);
500            accum.collect(&lattice, &spins, system_ids, true);
501            accum.q2_ac_buf.clone()
502        };
503        let identity: Vec<usize> = (0..4 * n_temps).collect();
504        // Swap replicas 1 and 2 at temperature 0 only.
505        let mut relabelled = identity.clone();
506        relabelled.swap(n_temps, 2 * n_temps);
507        assert_eq!(observable(&identity, 2), observable(&relabelled, 2));
508
509        // An odd count keeps its unpaired replica in the average.
510        let three = |system_ids: &[usize]| {
511            let mut accum =
512                OverlapAccum::new(n_temps, n, 1, lattice.n_neighbors, false, true).with_replicas(3);
513            accum.collect(&lattice, &spins, system_ids, true);
514            accum.q2_ac_buf.clone()
515        };
516        let mut rotated = identity[..3 * n_temps].to_vec();
517        rotated.swap(0, 2 * n_temps);
518        assert_eq!(three(&identity[..3 * n_temps]), three(&rotated));
519        assert_ne!(
520            three(&identity[..3 * n_temps]),
521            observable(&identity[..2 * n_temps], 1)
522        );
523
524        let two = observable(&identity[..2 * n_temps], 1);
525        for (t, value) in two.iter().enumerate() {
526            let dot: i64 = (0..n)
527                .map(|i| i64::from(spins[t * n + i] * spins[(n_temps + t) * n + i]))
528                .sum();
529            let q = dot as f64 / n as f64;
530            assert!((value - q * q).abs() < 1e-12);
531        }
532    }
533
534    use super::*;
535    use rand::{Rng, SeedableRng};
536    use rand_xoshiro::Xoshiro256StarStar;
537
538    /// The strided path must reproduce the neighbor-table walk, including double
539    /// bonds (extent 2) and unequal extents.
540    #[test]
541    fn strided_overlap_sums_match_the_table_walk() {
542        let mut rng = Xoshiro256StarStar::seed_from_u64(21);
543        for shape in [
544            vec![2, 2],
545            vec![3, 7],
546            vec![8, 5],
547            vec![2, 3, 4],
548            vec![5, 2, 3],
549            vec![4, 4, 4],
550        ] {
551            let lattice = Lattice::new(shape.clone());
552            let q: Vec<i8> = (0..lattice.n_spins)
553                .map(|_| if rng.gen::<bool>() { 1 } else { -1 })
554                .collect();
555            assert_eq!(
556                overlap_sums(&lattice, &q),
557                overlap_sums_chunked(&lattice, &q, 7),
558                "{shape:?}"
559            );
560        }
561    }
562
563    #[test]
564    fn chunked_overlap_sums_match_direct_definition() {
565        let lattice = Lattice::with_offsets(vec![5, 6], vec![vec![1, 0], vec![0, 1], vec![1, -1]]);
566        let mut rng = Xoshiro256StarStar::seed_from_u64(9);
567        let q: Vec<i8> = (0..lattice.n_spins)
568            .map(|_| if rng.gen::<bool>() { 1 } else { -1 })
569            .collect();
570        let spin: i64 = q.iter().map(|&v| v as i64).sum();
571        let link: i64 = (0..lattice.n_spins)
572            .flat_map(|i| (0..lattice.n_neighbors).map(move |d| (i, d)))
573            .map(|(i, d)| q[i] as i64 * q[lattice.neighbor_fwd(i, d)] as i64)
574            .sum();
575        assert_eq!(overlap_sums(&lattice, &q), (spin, link));
576        for chunk in [1, 7, 29, 30, 1000] {
577            assert_eq!(overlap_sums_chunked(&lattice, &q, chunk), (spin, link));
578        }
579    }
580
581    #[test]
582    fn empty_aggregate_is_empty() {
583        let empty = OverlapStats::aggregate(&[]);
584        assert!(empty.overlap.is_empty() && empty.per_sample_histogram.is_empty());
585    }
586}