physverify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. physverify/__init__.py +26 -0
  2. physverify/__main__.py +3 -0
  3. physverify/adapters/__init__.py +14 -0
  4. physverify/adapters/double_pendulum.py +55 -0
  5. physverify/adapters/lorenz.py +152 -0
  6. physverify/adapters/projectile_v1.py +92 -0
  7. physverify/adapters/projectile_v2.py +94 -0
  8. physverify/adapters/three_body.py +283 -0
  9. physverify/agent.py +265 -0
  10. physverify/checks.py +59 -0
  11. physverify/cli.py +105 -0
  12. physverify/compare.py +104 -0
  13. physverify/loaders.py +49 -0
  14. physverify/matrix.py +160 -0
  15. physverify/mms.py +118 -0
  16. physverify/order.py +131 -0
  17. physverify/problems/__init__.py +6 -0
  18. physverify/problems/_common.py +89 -0
  19. physverify/problems/heat1d.py +217 -0
  20. physverify/problems/kepler.py +176 -0
  21. physverify/problems/pendulum.py +201 -0
  22. physverify/problems/projectile.py +240 -0
  23. physverify/problems/sod.py +291 -0
  24. physverify/pytest_plugin.py +75 -0
  25. physverify/report.py +189 -0
  26. physverify/steppers.py +38 -0
  27. physverify/stochastic/__init__.py +1 -0
  28. physverify/stochastic/calibrate.py +202 -0
  29. physverify/stochastic/checks.py +378 -0
  30. physverify/stochastic/exact.py +205 -0
  31. physverify/stochastic/ising.py +170 -0
  32. physverify/stochastic/matrix.py +151 -0
  33. physverify/stochastic/reference.py +67 -0
  34. physverify/stochastic/sampler.py +253 -0
  35. physverify/stochastic/stats.py +137 -0
  36. physverify/studies.py +146 -0
  37. physverify/symbolic.py +89 -0
  38. physverify-0.1.0.dist-info/METADATA +122 -0
  39. physverify-0.1.0.dist-info/RECORD +43 -0
  40. physverify-0.1.0.dist-info/WHEEL +5 -0
  41. physverify-0.1.0.dist-info/entry_points.txt +5 -0
  42. physverify-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. physverify-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,170 @@
1
+ """Seeded-bug benchmark for Ising samplers (docs/plan_ising.md, section 5).
2
+
3
+ Each bug lives in one algorithm of physverify.stochastic.sampler.IsingSampler.
4
+ The predicted checks and the expected symptom were written down before any
5
+ bug was run. Unlike the deterministic benchmark, the test suite does not force
6
+ the predictions to come true: results/ising_matrix.md records predicted and
7
+ observed side by side, and wrong predictions are reported as such.
8
+
9
+ The equivalent mutant `reject_zero_de` is a negative control: it changes the
10
+ dynamics but keeps the Boltzmann distribution, so the distribution checks
11
+ should not flag it and the dynamics checks should.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ from . import checks as K
19
+ from .sampler import IsingSampler
20
+
21
+ NAME = "ising"
22
+ VARIANTS = ("metropolis", "heatbath", "checkerboard", "wolff")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class IsingBug:
27
+ algorithm: str
28
+ description: str
29
+ predicted: frozenset
30
+ symptom: str
31
+ negative_control: bool = False
32
+
33
+
34
+ def _f(*names):
35
+ return frozenset(names)
36
+
37
+
38
+ BUGS = {
39
+ "j_sign_flipped": IsingBug(
40
+ "metropolis", "coupling J = -1 (antiferromagnet) in the dynamics and the measurement",
41
+ _f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
42
+ "on an even lattice the energy distribution is unchanged (sublattice flip maps AF to FM), "
43
+ "but |m| collapses and bond correlations change sign",
44
+ ),
45
+ "delta_e_missing_factor_2": IsingBug(
46
+ "metropolis", "energy change of a flip computed as s * phi instead of 2 s * phi",
47
+ _f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
48
+ "samples at twice the temperature; the tracked energy drifts from the lattice",
49
+ ),
50
+ "exp_de_times_t": IsingBug(
51
+ "metropolis", "acceptance exp(-dE * T) instead of exp(-dE / T)",
52
+ _f("scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
53
+ "samples at temperature 1 / T^2",
54
+ ),
55
+ "missing_wrap_x": IsingBug(
56
+ "metropolis", "no periodic wrap in the x direction, in the dynamics and the measurement",
57
+ _f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
58
+ "a cylinder instead of a torus: wrap bonds uncorrelated, energies shifted",
59
+ ),
60
+ "same_neighbour_twice": IsingBug(
61
+ "metropolis", "the neighbour below is used twice and the one above not at all (dynamics only)",
62
+ _f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
63
+ "wrong local fields: wrong distribution, tracked energy drifts",
64
+ ),
65
+ "energy_double_counted": IsingBug(
66
+ "metropolis", "energy measurement counts every bond twice (dynamics right)",
67
+ _f("bookkeeping", "exact_distribution", "moments"),
68
+ "reported energies doubled; the lattice evolution is correct",
69
+ ),
70
+ "m_normalised_by_L": IsingBug(
71
+ "metropolis", "magnetization per spin divides by L instead of L^2 (reporting only)",
72
+ _f("exact_distribution", "moments"),
73
+ "reported m is L times too large; nothing else changes",
74
+ ),
75
+ "sweep_skips_last_row": IsingBug(
76
+ "metropolis", "random sites drawn from the first L - 1 rows only",
77
+ _f("exact_distribution", "moments", "symmetry"),
78
+ "the last row never changes; replay with the same site draws agrees",
79
+ ),
80
+ "same_random_number": IsingBug(
81
+ "metropolis", "one uniform used both to choose the site (int(u N)) and to accept",
82
+ _f("scripted_rng", "exact_distribution", "moments", "symmetry"),
83
+ "site-dependent acceptance: sites with high index rarely flip uphill; detailed balance broken",
84
+ ),
85
+ "seed_ignored": IsingBug(
86
+ "metropolis", "the generator is always seeded with the same constant",
87
+ _f("reproducibility"),
88
+ "every seed gives the same run; each run is still a correct sample",
89
+ ),
90
+ "acceptance_table_off_by_one": IsingBug(
91
+ "metropolis", "acceptance looked up one energy class too high (exp(-(dE + 4) / T))",
92
+ _f("scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
93
+ "uphill moves accepted too rarely; the lattice is too ordered",
94
+ ),
95
+ "incremental_energy_not_updated": IsingBug(
96
+ "metropolis", "the tracked energy is not updated after a flip",
97
+ _f("bookkeeping"),
98
+ "only the tracked energy is wrong; reported (measured) values are right",
99
+ ),
100
+ "no_burn_in": IsingBug(
101
+ "metropolis", "no burn-in: measurement starts from the initial lattice",
102
+ _f("equilibration"),
103
+ "hot and cold starts disagree over the first sweeps; long averages are barely biased",
104
+ ),
105
+ "naive_error_bars": IsingBug(
106
+ "metropolis", "error bar of the mean computed as std / sqrt(n), ignoring autocorrelation",
107
+ _f("error_bars"),
108
+ "reported error bars too small by about sqrt(2 tau)",
109
+ ),
110
+ "field_sign_wrong": IsingBug(
111
+ "metropolis", "the field enters the dynamics with the wrong sign (measurement right)",
112
+ _f("bookkeeping", "scripted_rng", "moments", "acceptance"),
113
+ "invisible at h = 0; in a field m has the wrong sign",
114
+ ),
115
+ "spins_zero_one": IsingBug(
116
+ "metropolis", "spins stored as {0, 1} and used in the +-1 formulas",
117
+ _f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
118
+ "a different model: energies and magnetizations off the allowed levels",
119
+ ),
120
+ "reject_zero_de": IsingBug(
121
+ "metropolis", "moves with dE = 0 are rejected instead of accepted (equivalent mutant)",
122
+ _f("scripted_rng", "acceptance"),
123
+ "same stationary distribution, different dynamics: only the dynamics checks should see it",
124
+ negative_control=True,
125
+ ),
126
+ "heatbath_missing_2": IsingBug(
127
+ "heatbath", "heat-bath probability 1 / (1 + exp(-phi / T)) instead of exp(-2 phi / T)",
128
+ _f("scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
129
+ "samples at twice the temperature",
130
+ ),
131
+ "synchronous_update": IsingBug(
132
+ "checkerboard", "all sites updated at once from the old lattice instead of two sublattices",
133
+ _f("bookkeeping", "exact_distribution", "moments"),
134
+ "neighbours flip together; wrong distribution, tracked energy wrong",
135
+ ),
136
+ "checkerboard_wrong_parity": IsingBug(
137
+ "checkerboard", "both half-sweeps update the even sublattice",
138
+ _f("exact_distribution", "moments", "symmetry"),
139
+ "the odd sublattice never changes",
140
+ ),
141
+ "wolff_probability": IsingBug(
142
+ "wolff", "cluster bond probability 1 - exp(-J / T) instead of 1 - exp(-2 J / T)",
143
+ _f("exact_distribution", "moments"),
144
+ "clusters too small; samples a different distribution",
145
+ ),
146
+ }
147
+
148
+
149
+ def variant_checks(sampler) -> list:
150
+ """The checks that apply to one sampler, with run lengths suited to its speed."""
151
+ if sampler.algorithm == "wolff": # one cluster update per 'sweep', looped in Python per chain
152
+ return K.ising_checks(
153
+ sampler,
154
+ exact_distribution={"n_chains": 128, "n_sweeps": 600},
155
+ moments={"n_chains": 128, "n_sweeps": 800},
156
+ symmetry={"n_chains": 128},
157
+ equilibration={"n_chains": 256},
158
+ error_bars={"n_chains": 64, "n_sweeps": 600},
159
+ )
160
+ return K.ising_checks(sampler)
161
+
162
+
163
+ def checks(bugs=frozenset(), algorithm=None):
164
+ """Checks for the sampler with the given bugs (all in one algorithm), or a bug-free variant."""
165
+ bugs = frozenset(bugs)
166
+ algorithms = {BUGS[b].algorithm for b in bugs}
167
+ if len(algorithms) > 1:
168
+ raise ValueError("bugs from different algorithms")
169
+ algorithm = algorithms.pop() if algorithms else (algorithm or "metropolis")
170
+ return variant_checks(IsingSampler(algorithm, bugs))
@@ -0,0 +1,151 @@
1
+ """Run every Ising check on every seeded bug; write the table, the prediction scorecard and the messages.
2
+
3
+ python -m physverify.stochastic.matrix results/ising_matrix.md
4
+
5
+ A check "flags" a bug when it returns FAIL or ERROR. INCONCLUSIVE is shown as
6
+ such and is neither a catch nor a pass. Takes several minutes (plain NumPy).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from ..checks import ERROR, FAIL, INCONCLUSIVE, run_check
16
+ from . import ising
17
+ from .checks import FAMILIES
18
+
19
+ SHORT = {"PASS": "ok", "EXACT": "ok", FAIL: "FAIL", ERROR: "ERROR", INCONCLUSIVE: "inconcl."}
20
+
21
+
22
+ def run_row(bug=None, algorithm=None):
23
+ bugs = frozenset([bug]) if bug else frozenset()
24
+ out = []
25
+ for check in ising.checks(bugs, algorithm):
26
+ start = time.perf_counter()
27
+ result = run_check(check)
28
+ out.append((check, result, time.perf_counter() - start))
29
+ return out
30
+
31
+
32
+ def run_all(progress=False):
33
+ rows = []
34
+ for alg in ising.VARIANTS:
35
+ rows.append((alg, None, run_row(None, alg)))
36
+ if progress:
37
+ print(f"baseline {alg} done", file=sys.stderr)
38
+ for bug, info in ising.BUGS.items():
39
+ rows.append((info.algorithm, bug, run_row(bug)))
40
+ if progress:
41
+ print(f"{bug} done", file=sys.stderr)
42
+ return rows
43
+
44
+
45
+ def flagged(results):
46
+ return {c.family for c, r, _ in results if r.status in (FAIL, ERROR)}
47
+
48
+
49
+ def to_markdown(rows) -> str:
50
+ head = "| Algorithm | Bug | " + " | ".join(FAMILIES) + " |\n|---|---|" + "---|" * len(FAMILIES) + "\n"
51
+ lines = []
52
+ for alg, bug, results in rows:
53
+ by_family = {c.family: r for c, r, _ in results}
54
+ predicted = ising.BUGS[bug].predicted if bug else frozenset()
55
+ cells = []
56
+ for fam in FAMILIES:
57
+ r = by_family.get(fam)
58
+ cell = SHORT.get(r.status, r.status) if r else "n/a"
59
+ if bug and fam in predicted:
60
+ cell += " (p)"
61
+ cells.append(cell)
62
+ lines.append(f"| {alg} | {bug or '(none)'} | " + " | ".join(cells) + " |")
63
+ bug_rows = [r for r in rows if r[1]]
64
+ controls = [r for r in bug_rows if ising.BUGS[r[1]].negative_control]
65
+ real = [r for r in bug_rows if not ising.BUGS[r[1]].negative_control]
66
+ caught = [r for r in real if flagged(r[2])]
67
+ baseline_alarms = [r for r in rows if not r[1] and flagged(r[2])]
68
+ baseline_inconclusive = sum(1 for r in rows if not r[1] for _, res, _ in r[2] if res.status == INCONCLUSIVE)
69
+ hit = miss = surprise = 0
70
+ for _, bug, results in bug_rows:
71
+ p, f = ising.BUGS[bug].predicted, flagged(results)
72
+ hit += len(p & f)
73
+ miss += len(p - f)
74
+ surprise += len(f - p)
75
+ summary = [
76
+ "",
77
+ "(p) marks a check predicted, before running, to flag the bug. ok = PASS or EXACT; inconcl. = INCONCLUSIVE; "
78
+ "n/a = the check does not apply to the algorithm (scripted_rng and acceptance need single-site updates).",
79
+ "",
80
+ f"Seeded bugs: {len(real)}, plus {len(controls)} equivalent mutant (negative control). "
81
+ f"Flagged by at least one check: {len(caught)} of {len(real)}"
82
+ + (" (missed: " + ", ".join(r[1] for r in real if not flagged(r[2])) + ")" if len(caught) < len(real) else "")
83
+ + ".",
84
+ f"Bug-free baselines with a FAIL: {len(baseline_alarms)} of {len(rows) - len(bug_rows)}; "
85
+ f"statistical INCONCLUSIVE on baselines: {baseline_inconclusive}.",
86
+ f"Predictions: {hit} predicted detections happened, {miss} predicted detections did not, "
87
+ f"{surprise} detections were not predicted (details in the scorecard).",
88
+ "Each statistical check has a false-alarm rate of at most 0.001 (Bonferroni within the check); "
89
+ "with 6 statistical checks per sampler the rate per sampler is at most 0.006.",
90
+ ]
91
+ return head + "\n".join(lines) + "\n" + "\n".join(summary) + "\n"
92
+
93
+
94
+ def scorecard(rows) -> str:
95
+ out = [
96
+ "# Ising seeded bugs: predictions against outcomes",
97
+ "",
98
+ "Predictions and symptoms were committed (src/physverify/stochastic/ising.py, commit 0297ffe) before any",
99
+ "bug was run, and have not been changed since.",
100
+ "",
101
+ "One check was changed after a result was seen. `scripted_rng` and `acceptance` first ran only in a field",
102
+ "(h = 0.25), where no single flip has dE = 0, so the equivalent mutant `reject_zero_de` was invisible to",
103
+ "both: a unit test showed this before the first full matrix run. Both checks now also run at h = 0. Their",
104
+ "detection of `reject_zero_de` below is therefore not a blind result.",
105
+ "",
106
+ ]
107
+ for alg, bug, results in rows:
108
+ if not bug:
109
+ continue
110
+ info = ising.BUGS[bug]
111
+ f = flagged(results)
112
+ out += [
113
+ f"## {bug} ({alg}){' — negative control' if info.negative_control else ''}",
114
+ "",
115
+ f"- Change: {info.description}",
116
+ f"- Expected symptom: {info.symptom}",
117
+ f"- Predicted: {', '.join(sorted(info.predicted)) or 'nothing'}",
118
+ f"- Flagged: {', '.join(sorted(f)) or 'nothing'}",
119
+ f"- Predicted but not flagged: {', '.join(sorted(info.predicted - f)) or '-'}",
120
+ f"- Flagged but not predicted: {', '.join(sorted(f - info.predicted)) or '-'}",
121
+ "",
122
+ ]
123
+ return "\n".join(out)
124
+
125
+
126
+ def details(rows) -> str:
127
+ out = []
128
+ for alg, bug, results in rows:
129
+ out.append(f"### {alg} / {bug or '(baseline)'}")
130
+ for c, r, secs in results:
131
+ out.append(f"- {c.family} [{c.label}] {r.status} ({secs:.1f} s): {r.message}")
132
+ out.append("")
133
+ return "\n".join(out)
134
+
135
+
136
+ def main(argv=None) -> int:
137
+ argv = sys.argv[1:] if argv is None else argv
138
+ rows = run_all(progress=True)
139
+ text = to_markdown(rows)
140
+ print(text)
141
+ if argv:
142
+ path = Path(argv[0])
143
+ path.parent.mkdir(parents=True, exist_ok=True)
144
+ path.write_text(text, encoding="utf-8")
145
+ path.with_name("ising_scorecard.md").write_text(scorecard(rows), encoding="utf-8")
146
+ path.with_name("ising_details.md").write_text(details(rows), encoding="utf-8")
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
@@ -0,0 +1,67 @@
1
+ """A trusted reference: one Ising chain in plain Python, written for clarity, not speed.
2
+
3
+ Used to replay a recorded random-number stream (the `scripted_rng` check) and as
4
+ the independent energy and magnetization functions for `bookkeeping`. The
5
+ vectorised sampler in `physverify.stochastic.sampler` must reproduce it exactly
6
+ when given the same random numbers; a test checks that.
7
+
8
+ Random-number protocol for single-site updates (Metropolis and heat bath): per
9
+ attempt, one integer site index k in [0, L*L) (row-major: i = k // L, j = k % L),
10
+ then one uniform u in [0, 1).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+
17
+ J = 1.0
18
+
19
+
20
+ def neighbour_sum(spins, i, j):
21
+ L = len(spins)
22
+ return spins[(i + 1) % L][j] + spins[(i - 1) % L][j] + spins[i][(j + 1) % L] + spins[i][(j - 1) % L]
23
+
24
+
25
+ def energy(spins, h=0.0):
26
+ """H = -J sum over right and down bonds of s_i s_j - h sum s_i."""
27
+ L = len(spins)
28
+ bonds = 0.0
29
+ for i in range(L):
30
+ for j in range(L):
31
+ bonds += spins[i][j] * (spins[i][(j + 1) % L] + spins[(i + 1) % L][j])
32
+ return -J * bonds - h * magnetization(spins)
33
+
34
+
35
+ def magnetization(spins):
36
+ return float(sum(sum(row) for row in spins))
37
+
38
+
39
+ def metropolis_attempt(spins, k, u, T, h=0.0):
40
+ """One Metropolis attempt at site k with uniform u; flips in place. Returns True if accepted."""
41
+ L = len(spins)
42
+ i, j = divmod(int(k), L)
43
+ s = spins[i][j]
44
+ dE = 2.0 * s * (J * neighbour_sum(spins, i, j) + h)
45
+ if dE <= 0.0 or u < math.exp(-dE / T):
46
+ spins[i][j] = -s
47
+ return True
48
+ return False
49
+
50
+
51
+ def heat_bath_attempt(spins, k, u, T, h=0.0):
52
+ """One heat-bath update at site k: the spin is set up with probability 1 / (1 + exp(-2 phi / T))."""
53
+ L = len(spins)
54
+ i, j = divmod(int(k), L)
55
+ phi = J * neighbour_sum(spins, i, j) + h
56
+ p_up = 1.0 / (1.0 + math.exp(-2.0 * phi / T))
57
+ spins[i][j] = 1 if u < p_up else -1
58
+ return True
59
+
60
+
61
+ def replay(spins, draws, T, h, algorithm="metropolis"):
62
+ """Apply a list of (site, u) draws in order; returns the final lattice (a copy)."""
63
+ spins = [list(row) for row in spins]
64
+ attempt = metropolis_attempt if algorithm == "metropolis" else heat_bath_attempt
65
+ for k, u in draws:
66
+ attempt(spins, k, u, T, h)
67
+ return spins
@@ -0,0 +1,253 @@
1
+ """The Ising sampler interface, and a vectorised sampler with switchable seeded bugs.
2
+
3
+ Interface a code under test provides (see docs/ising.md):
4
+
5
+ black box
6
+ sample(L, T, h, n_sweeps, seed, n_chains=1, start="hot") -> (e, m)
7
+ per-spin energy H / N and magnetization M / N after every sweep, arrays of
8
+ shape (n_chains, n_sweeps), after the code's own burn-in
9
+ error_bar(series) -> float
10
+ the code's error bar for the mean of one chain's series (optional)
11
+
12
+ white box (optional; checks that need a method the code lacks are skipped)
13
+ init(L, n_chains, start, rng) -> state state.spins (n_chains, L, L), state.E, state.M
14
+ sweep(state, T, h, rng) one sweep, updating state.E and state.M incrementally
15
+ step(state, sites, u, T, h) one single-site update per chain at the given
16
+ sites (flat index) with the given uniforms
17
+ measure(state, h) -> (E, M) totals computed from the lattice
18
+
19
+ `rng` follows numpy's Generator: the code may call only rng.integers(low, high,
20
+ size) and rng.random(size). For single-site algorithms the expected order is one
21
+ site array, then one uniform array, per attempt (physverify.stochastic.reference).
22
+
23
+ Many chains are run at once (arrays over the first axis), which makes the
24
+ statistical checks affordable in plain NumPy. The chains are independent.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import numpy as np
30
+
31
+ from .stats import blocked_error
32
+
33
+ ALGORITHMS = ("metropolis", "heatbath", "checkerboard", "wolff")
34
+
35
+
36
+ class State:
37
+ def __init__(self, spins, E, M):
38
+ self.spins, self.E, self.M = spins, E, M
39
+
40
+
41
+ class IsingSampler:
42
+ """Metropolis (random site), heat bath (random site), checkerboard Metropolis or Wolff.
43
+
44
+ `bugs` switches on seeded bugs by name (see physverify.stochastic.ising.BUGS).
45
+ With no bugs this sampler reproduces physverify.stochastic.reference exactly.
46
+ """
47
+
48
+ def __init__(self, algorithm="metropolis", bugs=frozenset(), burn_in=100):
49
+ if algorithm not in ALGORITHMS:
50
+ raise ValueError(algorithm)
51
+ self.algorithm = algorithm
52
+ self.bugs = frozenset(bugs)
53
+ self.burn_in = 0 if "no_burn_in" in self.bugs else burn_in
54
+ self.J = -1.0 if "j_sign_flipped" in self.bugs else 1.0
55
+ self.zero_one = "spins_zero_one" in self.bugs
56
+
57
+ # --- representation ---
58
+
59
+ def _up(self):
60
+ return 1.0
61
+
62
+ def _down(self):
63
+ return 0.0 if self.zero_one else -1.0
64
+
65
+ def _flipped(self, s):
66
+ return 1.0 - s if self.zero_one else -s
67
+
68
+ # --- lattice sums ---
69
+
70
+ def _tables(self, L):
71
+ """Neighbour indices (N, 4) and weights (N, 4) of every site, with the code's bugs applied."""
72
+ key = ("tables", L)
73
+ if key not in self.__dict__:
74
+ idx = np.empty((L * L, 4), dtype=np.intp)
75
+ w = np.ones((L * L, 4))
76
+ for i in range(L):
77
+ for j in range(L):
78
+ k = i * L + j
79
+ down, up = ((i + 1) % L) * L + j, ((i - 1) % L) * L + j
80
+ right, left = i * L + (j + 1) % L, i * L + (j - 1) % L
81
+ if "same_neighbour_twice" in self.bugs:
82
+ up = down
83
+ idx[k] = (down, up, right, left)
84
+ if "missing_wrap_x" in self.bugs:
85
+ w[k, 2] = 1.0 if j + 1 < L else 0.0
86
+ w[k, 3] = 1.0 if j - 1 >= 0 else 0.0
87
+ self.__dict__[key] = (idx, w)
88
+ return self.__dict__[key]
89
+
90
+ def _neighbour_sum(self, flat, sites, L):
91
+ """Sum of the four neighbours of the given site in every chain (flat: (n_chains, N))."""
92
+ idx, w = self._tables(L)
93
+ n_chains = flat.shape[0]
94
+ base = np.arange(n_chains) * (L * L)
95
+ values = flat.ravel()
96
+ total = 0.0
97
+ for d in range(4):
98
+ v = values[base + idx[sites, d]]
99
+ total = total + (v if "missing_wrap_x" not in self.bugs else v * w[sites, d])
100
+ return total
101
+
102
+ def _neighbour_sum_all(self, spins):
103
+ s = spins
104
+ down, up = np.roll(s, -1, axis=1), np.roll(s, 1, axis=1)
105
+ right, left = np.roll(s, -1, axis=2), np.roll(s, 1, axis=2)
106
+ return down + up + right + left
107
+
108
+ def measure(self, state, h=0.0):
109
+ s = state.spins
110
+ if "energy_double_counted" in self.bugs:
111
+ bonds = (s * self._neighbour_sum_all(s)).sum(axis=(1, 2))
112
+ else:
113
+ right = np.roll(s, -1, axis=2)
114
+ if "missing_wrap_x" in self.bugs:
115
+ right = right.copy()
116
+ right[:, :, -1] = 0.0
117
+ bonds = (s * (right + np.roll(s, -1, axis=1))).sum(axis=(1, 2))
118
+ M = s.sum(axis=(1, 2))
119
+ return -self.J * bonds - h * M, M
120
+
121
+ # --- set-up ---
122
+
123
+ def init(self, L, n_chains, start, rng, h=0.0):
124
+ if start == "cold":
125
+ spins = np.full((n_chains, L, L), self._up())
126
+ else:
127
+ spins = np.where(rng.random((n_chains, L, L)) < 0.5, self._up(), self._down())
128
+ state = State(spins, None, None)
129
+ state.E, state.M = self.measure(state, h)
130
+ return state
131
+
132
+ # --- single-site updates ---
133
+
134
+ def step(self, state, sites, u, T, h):
135
+ spins = state.spins
136
+ n_chains, L, _ = spins.shape
137
+ flat = spins.reshape(n_chains, L * L) # a view: writes go to state.spins
138
+ rows = np.arange(n_chains)
139
+ sites = np.asarray(sites, dtype=np.intp)
140
+ s = flat[rows, sites]
141
+ h_dyn = -h if "field_sign_wrong" in self.bugs else h
142
+ phi = self.J * self._neighbour_sum(flat, sites, L) + h_dyn
143
+ if self.algorithm == "heatbath":
144
+ factor = 1.0 if "heatbath_missing_2" in self.bugs else 2.0
145
+ p_up = 1.0 / (1.0 + np.exp(-factor * phi / T))
146
+ new = np.where(u < p_up, self._up(), self._down())
147
+ dE_track = -(new - s) * phi
148
+ else:
149
+ dE = (1.0 if "delta_e_missing_factor_2" in self.bugs else 2.0) * s * phi
150
+ with np.errstate(over="ignore"):
151
+ if "exp_de_times_t" in self.bugs:
152
+ p = np.exp(-dE * T)
153
+ elif "acceptance_table_off_by_one" in self.bugs:
154
+ p = np.exp(-(dE + 4.0) / T) # table entry for the next energy class
155
+ else:
156
+ p = np.exp(-dE / T)
157
+ accept = u < np.minimum(1.0, p)
158
+ if "reject_zero_de" in self.bugs:
159
+ accept &= dE != 0.0
160
+ new = np.where(accept, self._flipped(s), s)
161
+ dE_track = dE # the code tracks the energy with its own local energy change
162
+ changed = new != s
163
+ if "incremental_energy_not_updated" not in self.bugs:
164
+ state.E = state.E + np.where(changed, dE_track, 0.0)
165
+ state.M = state.M + (new - s)
166
+ flat[rows, sites] = new
167
+
168
+ # --- sweeps ---
169
+
170
+ def sweep(self, state, T, h, rng):
171
+ if self.algorithm in ("metropolis", "heatbath"):
172
+ n_chains, L, _ = state.spins.shape
173
+ N = L * L
174
+ for _ in range(N):
175
+ if "same_random_number" in self.bugs:
176
+ u = rng.random(n_chains)
177
+ sites = np.minimum((u * N).astype(int), N - 1)
178
+ else:
179
+ high = N - L if "sweep_skips_last_row" in self.bugs else N
180
+ sites = rng.integers(0, high, size=n_chains)
181
+ u = rng.random(n_chains)
182
+ self.step(state, sites, u, T, h)
183
+ elif self.algorithm == "checkerboard":
184
+ self._checkerboard_sweep(state, T, h, rng)
185
+ else:
186
+ self._wolff_update(state, T, h, rng)
187
+
188
+ def _checkerboard_sweep(self, state, T, h, rng):
189
+ s = state.spins
190
+ n_chains, L, _ = s.shape
191
+ ii, jj = np.meshgrid(np.arange(L), np.arange(L), indexing="ij")
192
+ if "synchronous_update" in self.bugs:
193
+ masks = [np.ones((L, L), dtype=bool)]
194
+ elif "checkerboard_wrong_parity" in self.bugs:
195
+ masks = [(ii + jj) % 2 == 0, (ii + jj) % 2 == 0]
196
+ else:
197
+ masks = [(ii + jj) % 2 == 0, (ii + jj) % 2 == 1]
198
+ for mask in masks:
199
+ phi = self.J * self._neighbour_sum_all(s) + h
200
+ dE = 2.0 * s * phi
201
+ u = rng.random((n_chains, L, L))
202
+ accept = mask & (u < np.minimum(1.0, np.exp(-dE / T)))
203
+ state.E = state.E + np.where(accept, dE, 0.0).sum(axis=(1, 2))
204
+ state.M = state.M + np.where(accept, -2.0 * s, 0.0).sum(axis=(1, 2))
205
+ s = np.where(accept, -s, s)
206
+ state.spins[...] = s
207
+
208
+ def _wolff_update(self, state, T, h, rng):
209
+ """One Wolff cluster flip per chain (h must be 0). A 'sweep' here is one cluster update."""
210
+ if h != 0.0:
211
+ raise ValueError("the Wolff update is implemented for h = 0 only")
212
+ n_chains, L, _ = state.spins.shape
213
+ k = 1.0 if "wolff_probability" in self.bugs else 2.0
214
+ p_add = 1.0 - np.exp(-k * self.J / T)
215
+ for c in range(n_chains):
216
+ s = state.spins[c]
217
+ seed = int(rng.integers(0, L * L))
218
+ i0, j0 = divmod(seed, L)
219
+ sign = s[i0, j0]
220
+ cluster = {(i0, j0)}
221
+ stack = [(i0, j0)]
222
+ while stack:
223
+ i, j = stack.pop()
224
+ for a, b in (((i + 1) % L, j), ((i - 1) % L, j), (i, (j + 1) % L), (i, (j - 1) % L)):
225
+ if (a, b) not in cluster and s[a, b] == sign and rng.random() < p_add:
226
+ cluster.add((a, b))
227
+ stack.append((a, b))
228
+ for a, b in cluster:
229
+ s[a, b] = -s[a, b]
230
+ state.E, state.M = self.measure(state, h)
231
+
232
+ # --- black box ---
233
+
234
+ def sample(self, L, T, h, n_sweeps, seed, n_chains=1, start="hot"):
235
+ rng = np.random.default_rng(12345 if "seed_ignored" in self.bugs else seed)
236
+ state = self.init(L, n_chains, start, rng, h)
237
+ for _ in range(self.burn_in):
238
+ self.sweep(state, T, h, rng)
239
+ e = np.empty((n_chains, n_sweeps))
240
+ m = np.empty((n_chains, n_sweeps))
241
+ N = L * L
242
+ for t in range(n_sweeps):
243
+ self.sweep(state, T, h, rng)
244
+ E, M = self.measure(state, h)
245
+ e[:, t] = E / N
246
+ m[:, t] = M / (L if "m_normalised_by_L" in self.bugs else N)
247
+ return e, m
248
+
249
+ def error_bar(self, series):
250
+ x = np.asarray(series, dtype=float)
251
+ if "naive_error_bars" in self.bugs:
252
+ return float(x.std() / np.sqrt(len(x)))
253
+ return blocked_error(x)