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,378 @@
1
+ """Checks for an Ising sampler (see physverify.stochastic.sampler for the interface).
2
+
3
+ Deterministic (plain PASS / FAIL, no statistics):
4
+
5
+ bookkeeping tracked E and M, the code's own measurement, and an independent
6
+ recomputation from the lattice all agree exactly after every sweep
7
+ scripted_rng the code's sweeps, fed recorded random numbers, give exactly the
8
+ lattices of the trusted reference replaying the same numbers
9
+ reproducibility the same seed gives the same run; different seeds do not
10
+
11
+ Statistical (PASS / FAIL / INCONCLUSIVE, false-alarm rate per check stated in the message):
12
+
13
+ exact_distribution fractions of time at each energy level and each |m| level, L = 4,
14
+ against exact enumeration
15
+ moments <e>, <e^2>, <|m|>, <m^2> at temperatures below, at and above T_c
16
+ (L = 4, enumeration), <m> in a field, and <e>, <e^2> at L = 8
17
+ (Kaufman's closed form)
18
+ acceptance single updates at controlled sites: acceptance frequency for each
19
+ energy change against min(1, exp(-dE / T)) (Metropolis) or the
20
+ heat-bath probability
21
+ symmetry translation and spin-flip symmetry: every bond's mean correlation
22
+ and every site's mean spin against the exact values
23
+ equilibration hot and cold starts agree from the first reported sweep
24
+ error_bars the code's reported error bars against the actual spread of
25
+ independent chains
26
+
27
+ Each check uses its own fixed seed, so the whole suite is deterministic.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import math
33
+
34
+ import numpy as np
35
+
36
+ from ..checks import EXACT, FAIL, INCONCLUSIVE, PASS, Check, Result
37
+ from . import exact as X
38
+ from . import reference as R
39
+ from .stats import ALPHA_CHECK, Comparison, compare_chain_means, t_critical, verdict
40
+
41
+ RESOLUTION = 0.02 # smallest deviation in a per-spin average (or a probability) that a statistical check must resolve
42
+ T_LOW, T_HIGH = 1.8, 3.0
43
+ T_DIST = 2.5
44
+
45
+
46
+ def _missing(sampler, *names):
47
+ return [n for n in names if not callable(getattr(sampler, n, None))]
48
+
49
+
50
+ def _skip(names) -> Result:
51
+ return Result(INCONCLUSIVE, f"not available: the sampler does not provide {', '.join(names)}")
52
+
53
+
54
+ # --- deterministic ---
55
+
56
+
57
+ def bookkeeping(sampler, *, L=4, h=0.25, n_chains=8, n_sweeps=40, seed=101) -> Check:
58
+ def run():
59
+ if _missing(sampler, "init", "sweep", "measure"):
60
+ return _skip(_missing(sampler, "init", "sweep", "measure"))
61
+ field = 0.0 if getattr(sampler, "algorithm", "") == "wolff" else h
62
+ rng = np.random.default_rng(seed)
63
+ state = sampler.init(L, n_chains, "hot", rng, field)
64
+ for sweep in range(1, n_sweeps + 1):
65
+ sampler.sweep(state, 2.5, field, rng)
66
+ E_code, M_code = sampler.measure(state, field)
67
+ for c in range(n_chains):
68
+ spins = state.spins[c].tolist()
69
+ E_ref, M_ref = R.energy(spins, field), R.magnetization(spins)
70
+ values = {
71
+ "tracked": (float(state.E[c]), float(state.M[c])),
72
+ "measured by the code": (float(E_code[c]), float(M_code[c])),
73
+ "recomputed independently": (E_ref, M_ref),
74
+ }
75
+ if len({v for v in values.values()}) > 1:
76
+ listing = "; ".join(f"{k} E={v[0]:g}, M={v[1]:g}" for k, v in values.items())
77
+ return Result(FAIL, f"after sweep {sweep}, chain {c}: {listing}")
78
+ return Result(EXACT, f"tracked, measured and recomputed E and M agree exactly over {n_sweeps} sweeps of {n_chains} chains")
79
+
80
+ return Check("bookkeeping", f"L = {L}, h = {h:g}", run)
81
+
82
+
83
+ class RecordingRNG:
84
+ """A numpy Generator that records every integers() and random() call, in order."""
85
+
86
+ def __init__(self, seed):
87
+ self._g = np.random.default_rng(seed)
88
+ self.log = []
89
+
90
+ def integers(self, low, high=None, size=None):
91
+ out = self._g.integers(low, high, size=size)
92
+ self.log.append(("integers", np.array(out, copy=True)))
93
+ return out
94
+
95
+ def random(self, size=None):
96
+ out = self._g.random(size)
97
+ self.log.append(("random", np.array(out, copy=True)))
98
+ return out
99
+
100
+
101
+ def scripted_rng(sampler, *, L=4, T=2.5, h=0.25, n_chains=4, n_sweeps=10, seed=202) -> Check:
102
+ algorithm = getattr(sampler, "algorithm", "metropolis")
103
+
104
+ def run():
105
+ if algorithm not in ("metropolis", "heatbath"):
106
+ return Result(INCONCLUSIVE, f"no reference replay for the {algorithm} algorithm")
107
+ if _missing(sampler, "init", "sweep"):
108
+ return _skip(_missing(sampler, "init", "sweep"))
109
+ for field in (0.0, h): # h = 0 has moves with dE = 0, a field has none: both are needed
110
+ result = _replay_once(field)
111
+ if result is not None:
112
+ return result
113
+ return Result(EXACT, f"{n_sweeps} sweeps of {n_chains} chains at h = 0 and h = {h:g} match the reference replay exactly")
114
+
115
+ def _replay_once(h):
116
+ state = sampler.init(L, n_chains, "hot", np.random.default_rng(seed), h)
117
+ lattices = [state.spins[c].tolist() for c in range(n_chains)]
118
+ for sweep in range(1, n_sweeps + 1):
119
+ rng = RecordingRNG(seed + sweep)
120
+ sampler.sweep(state, T, h, rng)
121
+ kinds = [k for k, _ in rng.log]
122
+ if kinds != ["integers", "random"] * (len(kinds) // 2) or len(kinds) != 2 * L * L:
123
+ return Result(
124
+ FAIL,
125
+ f"h = {h:g}, sweep {sweep} drew random numbers as {kinds[:4]}... ({len(kinds)} calls); expected "
126
+ f"{L * L} pairs of (site, uniform) as in physverify.stochastic.reference",
127
+ )
128
+ for c in range(n_chains):
129
+ draws = [(int(rng.log[2 * a][1][c]), float(rng.log[2 * a + 1][1][c])) for a in range(L * L)]
130
+ lattices[c] = R.replay(lattices[c], draws, T, h, algorithm)
131
+ if state.spins[c].tolist() != lattices[c]:
132
+ diff = int(np.sum(np.array(lattices[c]) != state.spins[c]))
133
+ return Result(FAIL, f"h = {h:g}, sweep {sweep}, chain {c}: lattice differs from the reference replay at {diff} sites")
134
+ return None
135
+
136
+ return Check("scripted_rng", f"{algorithm} replay, L = {L}, h = 0 and {h:g}", run)
137
+
138
+
139
+ def reproducibility(sampler, *, L=4, T=2.5, n_sweeps=20) -> Check:
140
+ def run():
141
+ a = sampler.sample(L, T, 0.0, n_sweeps, seed=1, n_chains=2)
142
+ b = sampler.sample(L, T, 0.0, n_sweeps, seed=1, n_chains=2)
143
+ c = sampler.sample(L, T, 0.0, n_sweeps, seed=2, n_chains=2)
144
+ if not all(np.array_equal(x, y) for x, y in zip(a, b)):
145
+ return Result(FAIL, "the same seed gave different runs")
146
+ if all(np.array_equal(x, y) for x, y in zip(a, c)):
147
+ return Result(FAIL, "different seeds gave identical runs: the seed is ignored")
148
+ return Result(PASS, "same seed reproduces the run; a different seed changes it")
149
+
150
+ return Check("reproducibility", "seeds", run)
151
+
152
+
153
+ # --- statistical ---
154
+
155
+
156
+ def _binned(series, levels, probs, min_p):
157
+ """Per chain fractions for the levels with probability >= min_p, plus one bin for the rest.
158
+
159
+ Returns (names, exact probabilities, per-chain fractions). Values that are on no
160
+ allowed level at all (a wrong normalisation, {0, 1} spins) fall in the rest bin.
161
+ """
162
+ keep = probs >= min_p
163
+ fractions = [(np.abs(series - lev) < 1e-9).mean(axis=1) for lev in levels[keep]]
164
+ rest = 1.0 - np.sum(fractions, axis=0)
165
+ names = [f"{lev:g}" for lev in levels[keep]] + ["any other value"]
166
+ return names, list(probs[keep]) + [float(1.0 - probs[keep].sum())], fractions + [rest]
167
+
168
+
169
+ def distribution_comparisons(e, m, L, T, min_p=0.01) -> list:
170
+ n_samples = e.size
171
+ comps = []
172
+ for symbol, series, (levels, probs) in (
173
+ ("e", e, X.energy_distribution(L, T)),
174
+ ("|m|", np.abs(m), X.abs_magnetization_distribution(L, T)),
175
+ ):
176
+ for name, p, f in zip(*_binned(series, levels, probs, min_p)):
177
+ floor = math.sqrt(max(p, 1.0 / n_samples) * (1 - min(p, 0.5)) / n_samples) # binomial lower bound
178
+ comps.append(compare_chain_means(f"P({symbol} = {name})" if name[0].isdigit() or name[0] == "-" else f"P({symbol} {name})", f, p, RESOLUTION, floor))
179
+ return comps
180
+
181
+
182
+ def exact_distribution(sampler, *, L=4, T=T_DIST, n_chains=512, n_sweeps=200, seed=303, alpha=ALPHA_CHECK) -> Check:
183
+ def run():
184
+ e, m = sampler.sample(L, T, 0.0, n_sweeps, seed=seed, n_chains=n_chains)
185
+ return verdict(distribution_comparisons(e, m, L, T), alpha)
186
+
187
+ return Check("exact_distribution", f"L = {L}, T = {T:g}", run)
188
+
189
+
190
+ def moment_comparisons(e, m, exact: dict, label: str, signed=False) -> list:
191
+ comps = [
192
+ compare_chain_means(f"<e> {label}", e.mean(axis=1), exact["e"], RESOLUTION),
193
+ compare_chain_means(f"<e^2> {label}", (e * e).mean(axis=1), exact["e2"], RESOLUTION * 4), # e^2 is of size 1 to 4
194
+ ]
195
+ if m is not None:
196
+ comps += [
197
+ compare_chain_means(f"<|m|> {label}", np.abs(m).mean(axis=1), exact["abs_m"], RESOLUTION),
198
+ compare_chain_means(f"<m^2> {label}", (m * m).mean(axis=1), exact["m2"], RESOLUTION),
199
+ ]
200
+ if signed:
201
+ comps.append(compare_chain_means(f"<m> {label}", m.mean(axis=1), exact["m"], RESOLUTION))
202
+ return comps
203
+
204
+
205
+ def moments(sampler, *, n_chains=512, n_sweeps=300, seed=404, alpha=ALPHA_CHECK) -> Check:
206
+ with_field = getattr(sampler, "algorithm", "") != "wolff"
207
+
208
+ def run():
209
+ comps = []
210
+ for k, T in enumerate((T_LOW, X.T_C, T_HIGH)):
211
+ e, m = sampler.sample(4, T, 0.0, n_sweeps, seed=seed + k, n_chains=n_chains)
212
+ comps += moment_comparisons(e, m, X.exact_moments(4, T), f"L=4 T={T:.3g}")
213
+ if with_field:
214
+ e, m = sampler.sample(4, T_HIGH, 0.5, n_sweeps, seed=seed + 10, n_chains=n_chains)
215
+ comps += moment_comparisons(e, m, X.exact_moments(4, T_HIGH, 0.5), "L=4 T=3 h=0.5", signed=True)
216
+ e, _ = sampler.sample(8, T_HIGH, 0.0, n_sweeps // 2, seed=seed + 20, n_chains=n_chains)
217
+ comps += moment_comparisons(e, None, X.kaufman_moments(8, T_HIGH), "L=8 T=3 (Kaufman)")
218
+ return verdict(comps, alpha)
219
+
220
+ label = "L = 4 at three temperatures" + (", in a field" if with_field else "") + ", L = 8"
221
+ return Check("moments", label, run)
222
+
223
+
224
+ def acceptance(sampler, *, L=4, T=2.5, h=0.25, n_chains=4096, rounds=4, seed=505, alpha=ALPHA_CHECK) -> Check:
225
+ algorithm = getattr(sampler, "algorithm", "metropolis")
226
+
227
+ def run():
228
+ if algorithm not in ("metropolis", "heatbath"):
229
+ return Result(INCONCLUSIVE, f"single-site acceptance does not apply to the {algorithm} algorithm")
230
+ if _missing(sampler, "init", "step"):
231
+ return _skip(_missing(sampler, "init", "step"))
232
+ rng = np.random.default_rng(seed)
233
+ stats = {} # (field, class) -> (expected probability, list of outcomes)
234
+ for field in (0.0, h): # h = 0 has the dE = 0 class, a field does not: both are needed
235
+ failure = _attempts(field, rng, stats)
236
+ if failure is not None:
237
+ return failure
238
+ comps = []
239
+ what = "accept" if algorithm == "metropolis" else "up"
240
+ for (field, key) in sorted(stats):
241
+ p, outs = stats[(field, key)]
242
+ if len(outs) < 50:
243
+ continue
244
+ outs = np.array(outs)
245
+ name = f"P({what} | {'dE' if algorithm == 'metropolis' else 'local field'} = {key:g}, h = {field:g})"
246
+ if p >= 1.0:
247
+ if outs.min() < 1.0:
248
+ return Result(FAIL, f"{name} should be 1 but {int((outs < 1).sum())} of {len(outs)} attempts were rejected")
249
+ continue
250
+ se = math.sqrt(p * (1 - p) / len(outs)) # binomial, independent attempts
251
+ comps.append(Comparison(name, float(outs.mean()), se, p, 0.02, 1e7))
252
+ if not comps:
253
+ return Result(INCONCLUSIVE, "no energy class was sampled often enough")
254
+ return verdict(comps, alpha)
255
+
256
+ def _attempts(h, rng, stats):
257
+ state = sampler.init(L, n_chains, "hot", rng, h)
258
+ for _ in range(rounds * L * L):
259
+ sites = rng.integers(0, L * L, size=n_chains)
260
+ u = rng.random(n_chains)
261
+ before = state.spins.reshape(n_chains, -1)[np.arange(n_chains), sites].copy()
262
+ flat = state.spins.reshape(n_chains, -1)
263
+ sp = state.spins # periodic neighbour sum, computed here independently of the sampler
264
+ nn_all = np.roll(sp, 1, 1) + np.roll(sp, -1, 1) + np.roll(sp, 1, 2) + np.roll(sp, -1, 2)
265
+ nn = nn_all.reshape(n_chains, -1)[np.arange(n_chains), sites]
266
+ phi = R.J * nn + h
267
+ sampler.step(state, sites, u, T, h)
268
+ after = flat[np.arange(n_chains), sites]
269
+ if algorithm == "metropolis":
270
+ dE = 2.0 * before * phi
271
+ keys = np.round(dE, 6)
272
+ outcome = after != before
273
+ expected = np.minimum(1.0, np.exp(-dE / T))
274
+ else:
275
+ keys = np.round(phi, 6)
276
+ outcome = after > 0
277
+ expected = 1.0 / (1.0 + np.exp(-2.0 * phi / T))
278
+ for key, o, p in zip(keys, outcome, expected):
279
+ stats.setdefault((h, float(key)), [float(p), []])[1].append(float(o))
280
+ return None
281
+
282
+ return Check("acceptance", f"{algorithm}, L = {L}, h = 0 and {h:g}", run)
283
+
284
+
285
+ def symmetry(sampler, *, L=4, T=T_HIGH, n_chains=256, n_sweeps=150, burn_in=100, seed=606, alpha=ALPHA_CHECK) -> Check:
286
+ def run():
287
+ if _missing(sampler, "init", "sweep"):
288
+ return _skip(_missing(sampler, "init", "sweep"))
289
+ rng = np.random.default_rng(seed)
290
+ state = sampler.init(L, n_chains, "hot", rng, 0.0)
291
+ for _ in range(burn_in):
292
+ sampler.sweep(state, T, 0.0, rng)
293
+ n = L * L
294
+ bond_sum = np.zeros((n_chains, 2, L, L))
295
+ site_sum = np.zeros((n_chains, L, L))
296
+ for _ in range(n_sweeps):
297
+ sampler.sweep(state, T, 0.0, rng)
298
+ s = state.spins
299
+ bond_sum[:, 0] += s * np.roll(s, -1, axis=2)
300
+ bond_sum[:, 1] += s * np.roll(s, -1, axis=1)
301
+ site_sum += s
302
+ ex = X.exact_moments(L, T)
303
+ bond_exact = -ex["e"] / 2.0 # 2N bonds, H = -sum of bond correlations
304
+ comps = []
305
+ for d, name in ((0, "right"), (1, "down")):
306
+ for i in range(L):
307
+ for j in range(L):
308
+ comps.append(compare_chain_means(f"<s s_{name}> at ({i},{j})", bond_sum[:, d, i, j] / n_sweeps, bond_exact, 0.05))
309
+ for i in range(L):
310
+ for j in range(L):
311
+ comps.append(compare_chain_means(f"<s> at ({i},{j})", site_sum[:, i, j] / n_sweeps, 0.0, 0.15))
312
+ return verdict(comps, alpha)
313
+
314
+ return Check("symmetry", f"bonds and sites, L = {L}, T = {T:g}", run)
315
+
316
+
317
+ def equilibration(sampler, *, L=4, T=2.0, n_chains=512, window=10, seed=707, alpha=ALPHA_CHECK) -> Check:
318
+ def run():
319
+ e_hot, _ = sampler.sample(L, T, 0.0, window, seed=seed, n_chains=n_chains, start="hot")
320
+ e_cold, _ = sampler.sample(L, T, 0.0, window, seed=seed + 1, n_chains=n_chains, start="cold")
321
+ a, b = e_hot.mean(axis=1), e_cold.mean(axis=1)
322
+ se = math.sqrt(a.var(ddof=1) / len(a) + b.var(ddof=1) / len(b))
323
+ comp = Comparison(f"<e> over the first {window} sweeps, hot minus cold start", float(a.mean() - b.mean()), se, 0.0, 2.5 * RESOLUTION, len(a) + len(b) - 2)
324
+ return verdict([comp], alpha)
325
+
326
+ return Check("equilibration", f"hot and cold starts, L = {L}, T = {T:g}", run)
327
+
328
+
329
+ def error_bars(sampler, *, L=4, T=None, n_chains=128, n_sweeps=1000, seed=808, alpha=ALPHA_CHECK) -> Check:
330
+ T = X.T_C if T is None else T
331
+
332
+ def run():
333
+ if _missing(sampler, "error_bar"):
334
+ return _skip(["error_bar"])
335
+ e, _ = sampler.sample(L, T, 0.0, n_sweeps, seed=seed, n_chains=n_chains)
336
+ reported = np.array([sampler.error_bar(e[c]) for c in range(n_chains)])
337
+ observed = float(e.mean(axis=1).std(ddof=1))
338
+ rms = float(np.sqrt(np.mean(reported**2)))
339
+ if not (np.isfinite(rms) and rms > 0):
340
+ return Result(FAIL, f"reported error bars are not positive and finite (rms {rms})")
341
+ # log of a sample standard deviation has standard error about 1 / sqrt(2 (n - 1))
342
+ comp = Comparison("log(observed spread / reported error bar)", math.log(observed / rms), 1.0 / math.sqrt(2 * (n_chains - 1)), 0.0, 0.3, 1e7)
343
+ result = verdict([comp], alpha)
344
+ result.message = f"observed spread of chain means {observed:.3g}, rms reported error bar {rms:.3g}. " + result.message
345
+ return result
346
+
347
+ return Check("error_bars", f"L = {L}, T = {T:.4g}", run)
348
+
349
+
350
+ ALL = (bookkeeping, scripted_rng, reproducibility, exact_distribution, moments, acceptance, symmetry, equilibration, error_bars)
351
+ FAMILIES = tuple(f.__name__ for f in ALL)
352
+
353
+
354
+ SINGLE_SITE = ("metropolis", "heatbath")
355
+ NEEDS = {
356
+ "bookkeeping": ("init", "sweep", "measure"),
357
+ "scripted_rng": ("init", "sweep"),
358
+ "acceptance": ("init", "step"),
359
+ "symmetry": ("init", "sweep"),
360
+ "error_bars": ("error_bar",),
361
+ }
362
+
363
+
364
+ def applicable(sampler, family: str) -> bool:
365
+ """False for a check the sampler cannot support: missing methods, or not a single-site algorithm."""
366
+ if family in ("scripted_rng", "acceptance") and getattr(sampler, "algorithm", "") not in SINGLE_SITE:
367
+ return False
368
+ return not _missing(sampler, *NEEDS.get(family, ()))
369
+
370
+
371
+ def ising_checks(sampler, **overrides) -> list[Check]:
372
+ """The checks that apply to this sampler (see `applicable`).
373
+
374
+ `overrides` maps a check name to keyword arguments for it, for example
375
+ {"moments": {"n_chains": 128}} for a slow sampler. Called directly, a check
376
+ that does not apply reports INCONCLUSIVE instead.
377
+ """
378
+ return [f(sampler, **overrides.get(f.__name__, {})) for f in ALL if applicable(sampler, f.__name__)]
@@ -0,0 +1,205 @@
1
+ """Exact reference values for the 2D Ising model on an L x L torus.
2
+
3
+ Convention throughout: H = -J sum_<ij> s_i s_j - h sum_i s_i with s = +1 or -1,
4
+ periodic boundaries, k_B = 1. The bond sum runs over the right and the down
5
+ neighbour of every site, 2 L^2 bonds (for L = 2 each pair is counted twice,
6
+ which is what a code looping over right and down neighbours does).
7
+
8
+ Three independent routes, cross-checked in the tests (milestone M0 of
9
+ docs/plan_ising.md):
10
+
11
+ enumeration every state for L <= 5: the exact joint density of states g(E, M)
12
+ kaufman Kaufman's closed form for the zero-field partition function of a
13
+ finite torus (as written by Beale, PRL 76, 78 (1996))
14
+ onsager the infinite-lattice energy, critical temperature and Yang's
15
+ spontaneous magnetization
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ from functools import lru_cache
22
+
23
+ import numpy as np
24
+
25
+ J = 1.0
26
+ T_C = 2.0 / math.log(1.0 + math.sqrt(2.0)) # Onsager, about 2.2692
27
+
28
+
29
+ # --- enumeration ---
30
+
31
+
32
+ @lru_cache(maxsize=None)
33
+ def density_of_states(L: int):
34
+ """Exact counts g[(E_bond + 2N) / 2, (M + N) / 2] for J = 1, as an integer array.
35
+
36
+ E_bond = -sum over the 2N right/down bonds of s_a s_b; M = sum of spins.
37
+ L = 5 has 2^25 states and takes several seconds.
38
+ """
39
+ if not 1 <= L <= 5:
40
+ raise ValueError("enumeration is limited to L <= 5")
41
+ n = L * L
42
+ bonds = []
43
+ for i in range(L):
44
+ for j in range(L):
45
+ k = i * L + j
46
+ bonds.append((k, i * L + (j + 1) % L))
47
+ bonds.append((k, ((i + 1) % L) * L + j))
48
+ counts = np.zeros((2 * n + 1, n + 1), dtype=np.int64)
49
+ chunk = 1 << min(n, 22)
50
+ for start in range(0, 1 << n, chunk):
51
+ x = np.arange(start, start + chunk, dtype=np.uint64)
52
+ up = np.zeros(chunk, dtype=np.int64)
53
+ for k in range(n):
54
+ up += ((x >> np.uint64(k)) & np.uint64(1)).astype(np.int64)
55
+ unlike = np.zeros(chunk, dtype=np.int64)
56
+ for a, b in bonds:
57
+ unlike += (((x >> np.uint64(a)) ^ (x >> np.uint64(b))) & np.uint64(1)).astype(np.int64)
58
+ energy = -(2 * n - 2 * unlike) # each like bond -1, each unlike bond +1
59
+ magnet = 2 * up - n
60
+ idx = ((energy + 2 * n) // 2) * (n + 1) + (magnet + n) // 2
61
+ counts += np.bincount(idx, minlength=counts.size).reshape(counts.shape)
62
+ return counts
63
+
64
+
65
+ def _levels(L):
66
+ n = L * L
67
+ energies = np.arange(-2 * n, 2 * n + 1, 2, dtype=float)
68
+ magnets = np.arange(-n, n + 1, 2, dtype=float)
69
+ return energies, magnets
70
+
71
+
72
+ def boltzmann_weights(L: int, T: float, h: float = 0.0):
73
+ """Normalised probabilities P(E_bond, M) on the (energy, magnetization) grid."""
74
+ g = density_of_states(L).astype(float)
75
+ energies, magnets = _levels(L)
76
+ H = J * energies[:, None] - h * magnets[None, :]
77
+ with np.errstate(divide="ignore"):
78
+ logw = np.where(g > 0, np.log(g) - H / T, -np.inf)
79
+ logw -= logw.max()
80
+ w = np.exp(logw)
81
+ return w / w.sum()
82
+
83
+
84
+ def exact_moments(L: int, T: float, h: float = 0.0) -> dict:
85
+ """Exact per-spin averages from enumeration. e is the total energy H / N (field term included)."""
86
+ n = L * L
87
+ P = boltzmann_weights(L, T, h)
88
+ energies, magnets = _levels(L)
89
+ e = (J * energies[:, None] - h * magnets[None, :]) / n
90
+ m = np.broadcast_to(magnets[None, :] / n, P.shape)
91
+ return {
92
+ "e": float((P * e).sum()),
93
+ "e2": float((P * e * e).sum()),
94
+ "m": float((P * m).sum()),
95
+ "abs_m": float((P * np.abs(m)).sum()),
96
+ "m2": float((P * m * m).sum()),
97
+ "m4": float((P * m**4).sum()),
98
+ }
99
+
100
+
101
+ def energy_distribution(L: int, T: float, h: float = 0.0):
102
+ """(levels of e = H / N at h = 0, probabilities). Only valid for h = 0, where H depends on E alone."""
103
+ if h != 0.0:
104
+ raise ValueError("the energy distribution is tabulated for h = 0 only")
105
+ energies, _ = _levels(L)
106
+ p = boltzmann_weights(L, T).sum(axis=1)
107
+ keep = p > 0
108
+ return J * energies[keep] / (L * L), p[keep]
109
+
110
+
111
+ def abs_magnetization_distribution(L: int, T: float, h: float = 0.0):
112
+ """(levels of |m|, probabilities)."""
113
+ _, magnets = _levels(L)
114
+ p = boltzmann_weights(L, T, h).sum(axis=0)
115
+ levels = np.abs(magnets) / (L * L)
116
+ out = {}
117
+ for lev, prob in zip(levels, p):
118
+ out[round(float(lev), 12)] = out.get(round(float(lev), 12), 0.0) + float(prob)
119
+ keys = sorted(out)
120
+ return np.array(keys), np.array([out[k] for k in keys])
121
+
122
+
123
+ # --- Kaufman's finite-torus partition function (h = 0) ---
124
+
125
+
126
+ def _log_abs_and_sign(values):
127
+ values = np.asarray(values, dtype=float)
128
+ sign = float(np.prod(np.sign(values)))
129
+ with np.errstate(divide="ignore"):
130
+ return float(np.sum(np.log(np.abs(values)))), sign
131
+
132
+
133
+ def kaufman_log_z(L: int, T: float, M: int | None = None) -> float:
134
+ """log Z of the zero-field Ising model on an L x M torus (M = L by default), J = 1.
135
+
136
+ Z = 1/2 (2 sinh 2K)^{LM/2} [Z1 + Z2 + Z3 + Z4],
137
+ Z1 = prod_{r<L} 2 cosh(M g_{2r+1} / 2), Z2 = prod 2 sinh(M g_{2r+1} / 2),
138
+ Z3 = prod_{r<L} 2 cosh(M g_{2r} / 2), Z4 = prod 2 sinh(M g_{2r} / 2),
139
+ cosh g_l = cosh 2K coth 2K - cos(pi l / L) for l >= 1, and g_0 = 2K + ln tanh K
140
+ (negative below T_c, which makes Z4 negative).
141
+ """
142
+ M = L if M is None else M
143
+ K = J / T
144
+ c = math.cosh(2 * K) / math.tanh(2 * K)
145
+ gam = np.empty(2 * L)
146
+ gam[0] = 2 * K + math.log(math.tanh(K))
147
+ for l in range(1, 2 * L):
148
+ gam[l] = math.acosh(c - math.cos(math.pi * l / L))
149
+ odd, even = gam[1::2], gam[0::2]
150
+ terms = [
151
+ _log_abs_and_sign(2 * np.cosh(M * odd / 2)),
152
+ _log_abs_and_sign(2 * np.sinh(M * odd / 2)),
153
+ _log_abs_and_sign(2 * np.cosh(M * even / 2)),
154
+ _log_abs_and_sign(2 * np.sinh(M * even / 2)),
155
+ ]
156
+ top = max(t[0] for t in terms if t[1] != 0)
157
+ total = sum(s * math.exp(lg - top) for lg, s in terms if s != 0)
158
+ return math.log(0.5) + 0.5 * L * M * math.log(2 * math.sinh(2 * K)) + top + math.log(total)
159
+
160
+
161
+ def kaufman_moments(L: int, T: float) -> dict:
162
+ """Per-spin energy e and e^2 at h = 0 from derivatives of log Z in beta (central differences)."""
163
+ n = L * L
164
+ beta = 1.0 / T
165
+ d = 1e-4 * beta
166
+
167
+ def logz(b):
168
+ return kaufman_log_z(L, 1.0 / b)
169
+
170
+ f0, fp, fm = logz(beta), logz(beta + d), logz(beta - d)
171
+ fpp, fmm = logz(beta + 2 * d), logz(beta - 2 * d)
172
+ d1 = (-fpp + 8 * fp - 8 * fm + fmm) / (12 * d)
173
+ d2 = (-fpp + 16 * fp - 30 * f0 + 16 * fm - fmm) / (12 * d * d)
174
+ mean_E = -d1
175
+ var_E = d2
176
+ return {"e": mean_E / n, "e2": (var_E + mean_E**2) / n**2}
177
+
178
+
179
+ # --- Onsager / Yang, infinite lattice ---
180
+
181
+
182
+ def _ellipk(k: float) -> float:
183
+ """Complete elliptic integral of the first kind K(k), modulus k < 1, by the AGM."""
184
+ a, b = 1.0, math.sqrt(1.0 - k * k)
185
+ for _ in range(60):
186
+ a, b = 0.5 * (a + b), math.sqrt(a * b)
187
+ if abs(a - b) < 1e-16 * a:
188
+ break
189
+ return math.pi / (2.0 * a)
190
+
191
+
192
+ def onsager_energy(T: float) -> float:
193
+ """Energy per spin of the infinite lattice at h = 0."""
194
+ K = J / T
195
+ if abs(T - T_C) < 1e-12:
196
+ return -math.sqrt(2.0) * J
197
+ k = 2.0 * math.sinh(2 * K) / math.cosh(2 * K) ** 2
198
+ return -J / math.tanh(2 * K) * (1.0 + 2.0 / math.pi * (2.0 * math.tanh(2 * K) ** 2 - 1.0) * _ellipk(k))
199
+
200
+
201
+ def yang_magnetization(T: float) -> float:
202
+ """Spontaneous magnetization per spin of the infinite lattice (0 above T_c)."""
203
+ if T >= T_C:
204
+ return 0.0
205
+ return (1.0 - math.sinh(2 * J / T) ** -4) ** 0.125