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.
- physverify/__init__.py +26 -0
- physverify/__main__.py +3 -0
- physverify/adapters/__init__.py +14 -0
- physverify/adapters/double_pendulum.py +55 -0
- physverify/adapters/lorenz.py +152 -0
- physverify/adapters/projectile_v1.py +92 -0
- physverify/adapters/projectile_v2.py +94 -0
- physverify/adapters/three_body.py +283 -0
- physverify/agent.py +265 -0
- physverify/checks.py +59 -0
- physverify/cli.py +105 -0
- physverify/compare.py +104 -0
- physverify/loaders.py +49 -0
- physverify/matrix.py +160 -0
- physverify/mms.py +118 -0
- physverify/order.py +131 -0
- physverify/problems/__init__.py +6 -0
- physverify/problems/_common.py +89 -0
- physverify/problems/heat1d.py +217 -0
- physverify/problems/kepler.py +176 -0
- physverify/problems/pendulum.py +201 -0
- physverify/problems/projectile.py +240 -0
- physverify/problems/sod.py +291 -0
- physverify/pytest_plugin.py +75 -0
- physverify/report.py +189 -0
- physverify/steppers.py +38 -0
- physverify/stochastic/__init__.py +1 -0
- physverify/stochastic/calibrate.py +202 -0
- physverify/stochastic/checks.py +378 -0
- physverify/stochastic/exact.py +205 -0
- physverify/stochastic/ising.py +170 -0
- physverify/stochastic/matrix.py +151 -0
- physverify/stochastic/reference.py +67 -0
- physverify/stochastic/sampler.py +253 -0
- physverify/stochastic/stats.py +137 -0
- physverify/studies.py +146 -0
- physverify/symbolic.py +89 -0
- physverify-0.1.0.dist-info/METADATA +122 -0
- physverify-0.1.0.dist-info/RECORD +43 -0
- physverify-0.1.0.dist-info/WHEEL +5 -0
- physverify-0.1.0.dist-info/entry_points.txt +5 -0
- physverify-0.1.0.dist-info/licenses/LICENSE +21 -0
- physverify-0.1.0.dist-info/top_level.txt +1 -0
physverify/report.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Run a list of checks and report the results as text, JSON or a self-contained HTML page."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .checks import Check, OrderResult, Result, run_check
|
|
13
|
+
from .loaders import load_module_from_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Outcome:
|
|
18
|
+
check: Check
|
|
19
|
+
result: Result
|
|
20
|
+
seconds: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_checks(checks) -> list[Outcome]:
|
|
24
|
+
out = []
|
|
25
|
+
for check in checks:
|
|
26
|
+
start = time.perf_counter()
|
|
27
|
+
result = run_check(check)
|
|
28
|
+
out.append(Outcome(check, result, time.perf_counter() - start))
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_spec(path):
|
|
33
|
+
"""Import a spec file and return its checks.
|
|
34
|
+
|
|
35
|
+
A spec is a Python file that defines `checks()`, returning a list of
|
|
36
|
+
`physverify.Check`. Its folder is put on sys.path first, so it can import
|
|
37
|
+
the solver next to it.
|
|
38
|
+
"""
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
path = Path(path).resolve()
|
|
42
|
+
folder = str(path.parent)
|
|
43
|
+
if folder not in sys.path:
|
|
44
|
+
sys.path.insert(0, folder)
|
|
45
|
+
name = "physverify_spec_" + "".join(c if c.isalnum() else "_" for c in str(path))
|
|
46
|
+
module = load_module_from_path(name, path)
|
|
47
|
+
if not callable(getattr(module, "checks", None)):
|
|
48
|
+
raise AttributeError(f"{path} does not define checks()")
|
|
49
|
+
checks = list(module.checks())
|
|
50
|
+
for check in checks:
|
|
51
|
+
if not isinstance(check, Check):
|
|
52
|
+
raise TypeError(f"{path}: checks() returned {type(check).__name__}, not physverify.Check")
|
|
53
|
+
return checks
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _number(x):
|
|
57
|
+
x = float(x)
|
|
58
|
+
return x if math.isfinite(x) else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def outcome_to_dict(o: Outcome) -> dict:
|
|
62
|
+
d = {
|
|
63
|
+
"family": o.check.family,
|
|
64
|
+
"label": o.check.label,
|
|
65
|
+
"status": o.result.status,
|
|
66
|
+
"ok": o.result.ok,
|
|
67
|
+
"message": o.result.message,
|
|
68
|
+
"seconds": round(o.seconds, 4),
|
|
69
|
+
}
|
|
70
|
+
if isinstance(o.result, OrderResult):
|
|
71
|
+
d["expected_order"] = _number(o.result.expected)
|
|
72
|
+
d["observed_order"] = _number(o.result.fitted)
|
|
73
|
+
d["h"] = [_number(h) for h in o.result.hs]
|
|
74
|
+
d["error"] = [_number(e) for e in o.result.errors]
|
|
75
|
+
return d
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def summary(outcomes) -> dict:
|
|
79
|
+
return {
|
|
80
|
+
"total": len(outcomes),
|
|
81
|
+
"ok": sum(o.result.ok for o in outcomes),
|
|
82
|
+
"not_ok": sum(not o.result.ok for o in outcomes),
|
|
83
|
+
"by_status": {s: sum(o.result.status == s for o in outcomes) for s in sorted({o.result.status for o in outcomes})},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def to_json(outcomes, source: str = "") -> str:
|
|
88
|
+
from . import __version__
|
|
89
|
+
|
|
90
|
+
payload = {
|
|
91
|
+
"physverify": __version__,
|
|
92
|
+
"source": source,
|
|
93
|
+
"summary": summary(outcomes),
|
|
94
|
+
"checks": [outcome_to_dict(o) for o in outcomes],
|
|
95
|
+
}
|
|
96
|
+
return json.dumps(payload, indent=2)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def to_text(outcomes) -> str:
|
|
100
|
+
lines = [f"{o.check.family:10s} {o.result.status:12s} {o.check.label}: {o.result.message}" for o in outcomes]
|
|
101
|
+
s = summary(outcomes)
|
|
102
|
+
lines.append(f"{s['ok']} of {s['total']} checks passed")
|
|
103
|
+
return "\n".join(lines)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _svg_plot(result: OrderResult, width=220, height=150) -> str:
|
|
107
|
+
"""Log-log plot of error against step size, with a line of the expected slope."""
|
|
108
|
+
pts = [(h, e) for h, e in zip(result.hs, result.errors) if h > 0 and e > 0 and math.isfinite(e)]
|
|
109
|
+
if len(pts) < 2:
|
|
110
|
+
return ""
|
|
111
|
+
lx = [math.log10(h) for h, _ in pts]
|
|
112
|
+
ly = [math.log10(e) for _, e in pts]
|
|
113
|
+
x0, x1 = min(lx), max(lx)
|
|
114
|
+
y0, y1 = min(ly), max(ly)
|
|
115
|
+
expected_line = []
|
|
116
|
+
if math.isfinite(result.expected):
|
|
117
|
+
# through the finest point, with the expected slope
|
|
118
|
+
yf = ly[-1] if lx[-1] == x0 else ly[lx.index(x0)]
|
|
119
|
+
expected_line = [(x0, yf), (x1, yf + result.expected * (x1 - x0))]
|
|
120
|
+
y0 = min(y0, *[p[1] for p in expected_line])
|
|
121
|
+
y1 = max(y1, *[p[1] for p in expected_line])
|
|
122
|
+
pad = 14
|
|
123
|
+
sx = (width - 2 * pad) / ((x1 - x0) or 1.0)
|
|
124
|
+
sy = (height - 2 * pad) / ((y1 - y0) or 1.0)
|
|
125
|
+
|
|
126
|
+
def xy(x, y):
|
|
127
|
+
return f"{pad + (x - x0) * sx:.1f},{height - pad - (y - y0) * sy:.1f}"
|
|
128
|
+
|
|
129
|
+
parts = [f'<svg viewBox="0 0 {width} {height}" width="{width}" height="{height}" role="img" aria-label="error against step size, log-log">']
|
|
130
|
+
parts.append(f'<rect x="0.5" y="0.5" width="{width - 1}" height="{height - 1}" class="frame"/>')
|
|
131
|
+
if expected_line:
|
|
132
|
+
parts.append(f'<polyline points="{xy(*expected_line[0])} {xy(*expected_line[1])}" class="expected"/>')
|
|
133
|
+
parts.append('<polyline points="' + " ".join(xy(x, y) for x, y in zip(lx, ly)) + '" class="observed"/>')
|
|
134
|
+
for x, y in zip(lx, ly):
|
|
135
|
+
cx, cy = xy(x, y).split(",")
|
|
136
|
+
parts.append(f'<circle cx="{cx}" cy="{cy}" r="2.5" class="point"/>')
|
|
137
|
+
parts.append("</svg>")
|
|
138
|
+
return "".join(parts)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_CSS = """
|
|
142
|
+
:root { --bg:#ffffff; --fg:#1d1d1f; --muted:#6b6b70; --line:#d9d9de; --ok:#1a7f37; --bad:#b42318; --warn:#9a6700; --accent:#2f5bd3; }
|
|
143
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#16171a; --fg:#ececf0; --muted:#a0a0a8; --line:#33343a; --ok:#4ac26b; --bad:#ff7b72; --warn:#d4a72c; --accent:#7aa2ff; } }
|
|
144
|
+
body { background:var(--bg); color:var(--fg); font:15px/1.5 system-ui, sans-serif; margin:0 auto; max-width:1100px; padding:24px 16px; }
|
|
145
|
+
h1 { font-size:22px; margin:0 0 4px; } p.meta { color:var(--muted); margin:0 0 20px; }
|
|
146
|
+
table { border-collapse:collapse; width:100%; } th, td { border-bottom:1px solid var(--line); padding:8px 6px; text-align:left; vertical-align:top; }
|
|
147
|
+
th { color:var(--muted); font-weight:600; font-size:13px; }
|
|
148
|
+
.status { font-weight:700; white-space:nowrap; } .ok { color:var(--ok); } .bad { color:var(--bad); } .warn { color:var(--warn); }
|
|
149
|
+
.wrap { overflow-x:auto; } svg .frame { fill:none; stroke:var(--line); } svg .observed { fill:none; stroke:var(--accent); stroke-width:2; }
|
|
150
|
+
svg .expected { fill:none; stroke:var(--muted); stroke-dasharray:4 3; } svg .point { fill:var(--accent); }
|
|
151
|
+
td.num { font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def to_html(outcomes, title: str = "physverify report", source: str = "") -> str:
|
|
156
|
+
from . import __version__
|
|
157
|
+
|
|
158
|
+
s = summary(outcomes)
|
|
159
|
+
rows = []
|
|
160
|
+
for o in outcomes:
|
|
161
|
+
r = o.result
|
|
162
|
+
cls = "ok" if r.ok else ("warn" if r.status == "INCONCLUSIVE" else "bad")
|
|
163
|
+
plot = _svg_plot(r) if isinstance(r, OrderResult) else ""
|
|
164
|
+
order = ""
|
|
165
|
+
if isinstance(r, OrderResult) and math.isfinite(r.fitted):
|
|
166
|
+
order = f"{r.fitted:.2f}" + (f" (expected {r.expected:g})" if math.isfinite(r.expected) else "")
|
|
167
|
+
rows.append(
|
|
168
|
+
"<tr>"
|
|
169
|
+
f'<td class="status {cls}">{html.escape(r.status)}</td>'
|
|
170
|
+
f"<td>{html.escape(o.check.family)}</td>"
|
|
171
|
+
f"<td>{html.escape(o.check.label)}</td>"
|
|
172
|
+
f"<td>{html.escape(r.message)}</td>"
|
|
173
|
+
f'<td class="num">{html.escape(order)}</td>'
|
|
174
|
+
f"<td>{plot}</td>"
|
|
175
|
+
"</tr>"
|
|
176
|
+
)
|
|
177
|
+
return (
|
|
178
|
+
"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">"
|
|
179
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
180
|
+
f"<title>{html.escape(title)}</title><style>{_CSS}</style></head><body>"
|
|
181
|
+
f"<h1>{html.escape(title)}</h1>"
|
|
182
|
+
f'<p class="meta">{s["ok"]} of {s["total"]} checks passed. physverify {html.escape(__version__)}'
|
|
183
|
+
+ (f" · {html.escape(source)}" if source else "")
|
|
184
|
+
+ ". Plots: error against step size on log-log axes; dashed line has the expected slope.</p>"
|
|
185
|
+
'<div class="wrap"><table><thead><tr><th>Status</th><th>Family</th><th>Check</th><th>Message</th>'
|
|
186
|
+
"<th>Observed order</th><th>Convergence</th></tr></thead><tbody>"
|
|
187
|
+
+ "".join(rows)
|
|
188
|
+
+ "</tbody></table></div></body></html>\n"
|
|
189
|
+
)
|
physverify/steppers.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Reference time steppers and a fixed-step driver.
|
|
2
|
+
|
|
3
|
+
These are used (a) to build independent reference solutions and (b) as the
|
|
4
|
+
stepper when only a right-hand side, not a whole solver, is under test.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def euler(f, t, y, h):
|
|
13
|
+
return y + h * f(t, y)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def midpoint(f, t, y, h):
|
|
17
|
+
k1 = f(t, y)
|
|
18
|
+
return y + h * f(t + 0.5 * h, y + 0.5 * h * k1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def rk4(f, t, y, h):
|
|
22
|
+
k1 = f(t, y)
|
|
23
|
+
k2 = f(t + 0.5 * h, y + 0.5 * h * k1)
|
|
24
|
+
k3 = f(t + 0.5 * h, y + 0.5 * h * k2)
|
|
25
|
+
k4 = f(t + h, y + h * k3)
|
|
26
|
+
return y + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
STEPPERS = {"euler": (euler, 1), "midpoint": (midpoint, 2), "rk4": (rk4, 4)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def integrate_fixed(f, y0, t0, t1, n_steps, stepper=rk4):
|
|
33
|
+
"""Advance y' = f(t, y) from t0 to t1 in n_steps equal steps."""
|
|
34
|
+
h = (t1 - t0) / n_steps
|
|
35
|
+
y = np.array(y0, dtype=float)
|
|
36
|
+
for i in range(n_steps):
|
|
37
|
+
y = stepper(f, t0 + i * h, y, h)
|
|
38
|
+
return y
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Verification checks for stochastic (Monte Carlo) codes: the 2D Ising model. See docs/ising.md."""
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Calibration of the statistical checks, and detection power against run length.
|
|
2
|
+
|
|
3
|
+
python -m physverify.stochastic.calibrate results/ising_calibration.md
|
|
4
|
+
|
|
5
|
+
Calibration (milestone M2): many independent replicate runs of the bug-free
|
|
6
|
+
sampler, each analysed exactly as a check would. Two rates are counted:
|
|
7
|
+
|
|
8
|
+
* per comparison, the fraction of single t-tests rejected at nominal level a.
|
|
9
|
+
This should be close to a; if it is much larger, the error bars are too small.
|
|
10
|
+
* per check, the fraction of verdicts that are FAIL at the check's
|
|
11
|
+
false-alarm rate alpha (Bonferroni over its comparisons). It must not exceed
|
|
12
|
+
alpha, and is expected to be below it because Bonferroni is conservative.
|
|
13
|
+
|
|
14
|
+
Power (milestone M3): for a few bugs, the probability that the exact-distribution
|
|
15
|
+
check flags the bug, as a function of the number of sweeps per chain.
|
|
16
|
+
|
|
17
|
+
The replicate runs are simulated together as one large set of independent chains.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from ..checks import FAIL
|
|
29
|
+
from . import exact as X
|
|
30
|
+
from .checks import distribution_comparisons, moment_comparisons
|
|
31
|
+
from .sampler import IsingSampler
|
|
32
|
+
from .stats import t_critical, verdict
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def binomial_interval(k, n, z=1.96):
|
|
36
|
+
"""Wilson score interval for a proportion."""
|
|
37
|
+
if n == 0:
|
|
38
|
+
return (0.0, 1.0)
|
|
39
|
+
p = k / n
|
|
40
|
+
centre = (p + z * z / (2 * n)) / (1 + z * z / n)
|
|
41
|
+
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
|
|
42
|
+
return max(0.0, centre - half), min(1.0, centre + half)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _groups(e, m, R, C):
|
|
46
|
+
for g in range(R):
|
|
47
|
+
yield e[g * C:(g + 1) * C], m[g * C:(g + 1) * C]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def calibrate(R=1000, C=32, n_sweeps=200, seed=2026, levels=(0.05, 0.01), alpha_check=0.05):
|
|
51
|
+
"""Returns rows (test, T, per-comparison rejection rates at each level, FAIL rate of the check)."""
|
|
52
|
+
sampler = IsingSampler("metropolis")
|
|
53
|
+
rows = []
|
|
54
|
+
for name, T in (("exact_distribution", 2.5), ("moments", X.T_C), ("moments", 1.8)):
|
|
55
|
+
e, m = sampler.sample(4, T, 0.0, n_sweeps, seed=seed, n_chains=R * C)
|
|
56
|
+
seed += 1
|
|
57
|
+
per_level = {a: [0, 0] for a in levels}
|
|
58
|
+
fails = 0
|
|
59
|
+
for eg, mg in _groups(e, m, R, C):
|
|
60
|
+
if name == "exact_distribution":
|
|
61
|
+
comps = distribution_comparisons(eg, mg, 4, T)
|
|
62
|
+
else:
|
|
63
|
+
comps = moment_comparisons(eg, mg, X.exact_moments(4, T), "")
|
|
64
|
+
for c in comps:
|
|
65
|
+
for a in levels:
|
|
66
|
+
crit = t_critical(a, float(c.dof))
|
|
67
|
+
per_level[a][0] += abs(c.estimate - c.exact) > crit * c.se
|
|
68
|
+
per_level[a][1] += 1
|
|
69
|
+
fails += verdict(comps, alpha_check).status == FAIL
|
|
70
|
+
rows.append((name, T, {a: tuple(v) for a, v in per_level.items()}, fails, R))
|
|
71
|
+
return rows
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
POWER_BUGS = (
|
|
75
|
+
None,
|
|
76
|
+
"reject_zero_de",
|
|
77
|
+
"exp_de_times_t",
|
|
78
|
+
"acceptance_table_off_by_one",
|
|
79
|
+
"same_random_number",
|
|
80
|
+
"sweep_skips_last_row",
|
|
81
|
+
"heatbath_missing_2",
|
|
82
|
+
"synchronous_update",
|
|
83
|
+
)
|
|
84
|
+
POWER_SWEEPS = (10, 25, 50, 100, 200)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def power(R=50, C=16, sweeps=POWER_SWEEPS, seed=3000, T=2.5):
|
|
88
|
+
"""Detection probability of exact_distribution (alpha 0.001) for each bug and run length."""
|
|
89
|
+
from . import ising
|
|
90
|
+
|
|
91
|
+
table = {}
|
|
92
|
+
for bug in POWER_BUGS:
|
|
93
|
+
algorithm = ising.BUGS[bug].algorithm if bug else "metropolis"
|
|
94
|
+
sampler = IsingSampler(algorithm, frozenset([bug]) if bug else frozenset())
|
|
95
|
+
probs = []
|
|
96
|
+
for n in sweeps:
|
|
97
|
+
e, m = sampler.sample(4, T, 0.0, n, seed=seed, n_chains=R * C)
|
|
98
|
+
seed += 1
|
|
99
|
+
hits = sum(verdict(distribution_comparisons(eg, mg, 4, T)).status == FAIL for eg, mg in _groups(e, m, R, C))
|
|
100
|
+
probs.append(hits / R)
|
|
101
|
+
table[bug or "(no bug)"] = probs
|
|
102
|
+
return table
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def power_svg(table, sweeps=POWER_SWEEPS, width=560, height=300) -> str:
|
|
106
|
+
"""A small line chart of detection probability against sweeps per chain (log x axis)."""
|
|
107
|
+
pad_l, pad_r, pad_t, pad_b = 46, 170, 14, 36
|
|
108
|
+
x0, x1 = math.log10(sweeps[0]), math.log10(sweeps[-1])
|
|
109
|
+
|
|
110
|
+
def px(n):
|
|
111
|
+
return pad_l + (math.log10(n) - x0) / (x1 - x0) * (width - pad_l - pad_r)
|
|
112
|
+
|
|
113
|
+
def py(p):
|
|
114
|
+
return pad_t + (1 - p) * (height - pad_t - pad_b)
|
|
115
|
+
|
|
116
|
+
colours = ["#6b6b70", "#9a6700", "#2f5bd3", "#1a7f37", "#b42318", "#8250df", "#0a7ea4", "#bf3989"]
|
|
117
|
+
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" font-family="sans-serif" font-size="11">']
|
|
118
|
+
parts.append(f'<rect width="{width}" height="{height}" fill="white"/>')
|
|
119
|
+
for p in (0, 0.5, 1):
|
|
120
|
+
parts.append(f'<line x1="{pad_l}" x2="{width - pad_r}" y1="{py(p):.1f}" y2="{py(p):.1f}" stroke="#ddd"/>')
|
|
121
|
+
parts.append(f'<text x="{pad_l - 6}" y="{py(p) + 4:.1f}" text-anchor="end">{p:g}</text>')
|
|
122
|
+
for n in sweeps:
|
|
123
|
+
parts.append(f'<text x="{px(n):.1f}" y="{height - pad_b + 16}" text-anchor="middle">{n}</text>')
|
|
124
|
+
parts.append(f'<text x="{(pad_l + width - pad_r) / 2:.1f}" y="{height - 4}" text-anchor="middle">sweeps per chain (16 chains per run)</text>')
|
|
125
|
+
parts.append(f'<text x="12" y="{(pad_t + height - pad_b) / 2:.1f}" transform="rotate(-90 12 {(pad_t + height - pad_b) / 2:.1f})" text-anchor="middle">P(flagged)</text>')
|
|
126
|
+
for k, (name, probs) in enumerate(table.items()):
|
|
127
|
+
col = colours[k % len(colours)]
|
|
128
|
+
pts = " ".join(f"{px(n):.1f},{py(p):.1f}" for n, p in zip(sweeps, probs))
|
|
129
|
+
dash = ' stroke-dasharray="4 3"' if name in ("(no bug)", "reject_zero_de") else ""
|
|
130
|
+
parts.append(f'<polyline points="{pts}" fill="none" stroke="{col}" stroke-width="2"{dash}/>')
|
|
131
|
+
parts.append(f'<text x="{width - pad_r + 8}" y="{pad_t + 12 + 15 * k}" fill="{col}">{name}</text>')
|
|
132
|
+
parts.append("</svg>")
|
|
133
|
+
return "\n".join(parts) + "\n"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def to_markdown(cal, table, R_power=50) -> str:
|
|
137
|
+
lines = [
|
|
138
|
+
"# Ising checks: calibration and power",
|
|
139
|
+
"",
|
|
140
|
+
"Generated by `python -m physverify.stochastic.calibrate results/ising_calibration.md`.",
|
|
141
|
+
"",
|
|
142
|
+
"## Calibration on the bug-free Metropolis sampler",
|
|
143
|
+
"",
|
|
144
|
+
"Each replicate is 32 independent chains of 200 sweeps on L = 4 (after the sampler's burn-in of 100),",
|
|
145
|
+
"analysed exactly as the check analyses its chains. 95% Wilson intervals in brackets.",
|
|
146
|
+
"",
|
|
147
|
+
"| Test | T | Comparisons | Rejected at 0.05 | Rejected at 0.01 | Check FAIL rate at alpha = 0.05 |",
|
|
148
|
+
"|---|---|---|---|---|---|",
|
|
149
|
+
]
|
|
150
|
+
for name, T, per, fails, R in cal:
|
|
151
|
+
k5, n5 = per[0.05]
|
|
152
|
+
k1, n1 = per[0.01]
|
|
153
|
+
lo5, hi5 = binomial_interval(k5, n5)
|
|
154
|
+
lo1, hi1 = binomial_interval(k1, n1)
|
|
155
|
+
lo, hi = binomial_interval(fails, R)
|
|
156
|
+
lines.append(
|
|
157
|
+
f"| {name} | {T:.4g} | {n5} | {k5 / n5:.4f} [{lo5:.4f}, {hi5:.4f}] | {k1 / n1:.4f} [{lo1:.4f}, {hi1:.4f}] "
|
|
158
|
+
f"| {fails} of {R} = {fails / R:.3f} [{lo:.3f}, {hi:.3f}] |"
|
|
159
|
+
)
|
|
160
|
+
lines += [
|
|
161
|
+
"",
|
|
162
|
+
"Reading: per-comparison rejection rates near the nominal level mean the error bars are right; the check-level",
|
|
163
|
+
"FAIL rate must stay at or below its alpha (Bonferroni makes it conservative). The checks themselves use",
|
|
164
|
+
"alpha = 0.001, which cannot be measured directly with 1000 replicates; the 0.05 runs test the same machinery.",
|
|
165
|
+
"",
|
|
166
|
+
"## Power: probability that `exact_distribution` flags a bug",
|
|
167
|
+
"",
|
|
168
|
+
f"{R_power} replicate runs per cell, each 16 chains on L = 4 at T = 2.5, alpha = 0.001.",
|
|
169
|
+
"",
|
|
170
|
+
"| Bug | " + " | ".join(f"{n} sweeps" for n in POWER_SWEEPS) + " |",
|
|
171
|
+
"|---|" + "---|" * len(POWER_SWEEPS),
|
|
172
|
+
]
|
|
173
|
+
for name, probs in table.items():
|
|
174
|
+
lines.append(f"| {name} | " + " | ".join(f"{p:.2f}" for p in probs) + " |")
|
|
175
|
+
lines += [
|
|
176
|
+
"",
|
|
177
|
+
"`(no bug)` should stay near 0 (false alarms). `reject_zero_de` is the equivalent mutant: it keeps the",
|
|
178
|
+
"Boltzmann distribution, so a distribution check should not flag it at any run length.",
|
|
179
|
+
"",
|
|
180
|
+
"",
|
|
181
|
+
"",
|
|
182
|
+
]
|
|
183
|
+
return "\n".join(lines)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main(argv=None) -> int:
|
|
187
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
188
|
+
cal = calibrate()
|
|
189
|
+
print("calibration done", file=sys.stderr)
|
|
190
|
+
table = power()
|
|
191
|
+
text = to_markdown(cal, table)
|
|
192
|
+
print(text)
|
|
193
|
+
if argv:
|
|
194
|
+
path = Path(argv[0])
|
|
195
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
path.write_text(text, encoding="utf-8")
|
|
197
|
+
path.with_name("ising_power.svg").write_text(power_svg(table), encoding="utf-8")
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
raise SystemExit(main())
|