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
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Adapter for three-body-simulation/src (planar three-body problem, G = 1).
|
|
2
|
+
|
|
3
|
+
Under test:
|
|
4
|
+
physics.get_derivatives stepped with physverify's RK4 (mms, reference, patch)
|
|
5
|
+
integrators.verlet_step the project's own velocity-Verlet step (second order)
|
|
6
|
+
integrators.run_verlet_simulation the driver around it
|
|
7
|
+
integrators.run_simulation the solve_ivp wrapper (needs SciPy)
|
|
8
|
+
|
|
9
|
+
The code softens gravity with epsilon = 1e-4, as docs/theory.md allows
|
|
10
|
+
("(r^2 + eps^2)^1.5"), so the reference uses the same softening. The energy
|
|
11
|
+
function in physics.py does not soften; that mismatch is reported separately.
|
|
12
|
+
Verlet has no source-term hook, so there is no MMS check of it (row 25 of the
|
|
13
|
+
audit plan); it is covered by a reference solution, exact invariants and exact
|
|
14
|
+
time reversal instead. The package uses relative imports and SciPy, so it is
|
|
15
|
+
loaded as a package; without SciPy only physics.py is tested.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import importlib.util
|
|
21
|
+
import math
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from ..checks import EXACT, FAIL, PASS, Check, Result
|
|
27
|
+
from ..mms import ManufacturedODE, max_abs_error, zero_source
|
|
28
|
+
from ..order import check_exact, check_order
|
|
29
|
+
from ..problems._common import make_integrate
|
|
30
|
+
from ..steppers import integrate_fixed, rk4
|
|
31
|
+
from ..studies import invariant_study, ode_checks, reference_study
|
|
32
|
+
from . import sibling_project
|
|
33
|
+
|
|
34
|
+
ENV_VAR = "PHYSVERIFY_THREE_BODY_DIR"
|
|
35
|
+
FOLDER = "three-body-simulation"
|
|
36
|
+
EPS = 1e-4 # softening length used by physics.py
|
|
37
|
+
MASSES = np.array([1.0, 1.0, 1.0])
|
|
38
|
+
|
|
39
|
+
# Figure-eight initial condition (Chenciner and Montgomery), used only as a smooth, bounded test orbit.
|
|
40
|
+
_X1 = np.array([0.97000436, -0.24308753])
|
|
41
|
+
_V3 = np.array([-0.93240737, -0.86473146])
|
|
42
|
+
Y0_FREE = np.concatenate([_X1, -_X1, [0.0, 0.0], -_V3 / 2, -_V3 / 2, _V3])
|
|
43
|
+
T_END = 1.0
|
|
44
|
+
NS_RK4 = (100, 200, 400, 800)
|
|
45
|
+
NS_VERLET = (200, 400, 800, 1600)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def src_dir():
|
|
49
|
+
return sibling_project(ENV_VAR, FOLDER) / "src"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def source_path():
|
|
53
|
+
return src_dir() / "physics.py"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load():
|
|
57
|
+
"""Import the project's src package under a private name, without writing __pycache__."""
|
|
58
|
+
name = "three_body_src_under_test"
|
|
59
|
+
old_flag = sys.dont_write_bytecode
|
|
60
|
+
sys.dont_write_bytecode = True
|
|
61
|
+
try:
|
|
62
|
+
spec = importlib.util.spec_from_file_location(
|
|
63
|
+
name, src_dir() / "__init__.py", submodule_search_locations=[str(src_dir())]
|
|
64
|
+
)
|
|
65
|
+
package = importlib.util.module_from_spec(spec)
|
|
66
|
+
sys.modules[name] = package
|
|
67
|
+
try:
|
|
68
|
+
spec.loader.exec_module(package)
|
|
69
|
+
except ImportError:
|
|
70
|
+
# __init__ imports integrators, which needs SciPy; fall back to physics.py alone
|
|
71
|
+
for key in [k for k in sys.modules if k == name or k.startswith(name + ".")]:
|
|
72
|
+
sys.modules.pop(key)
|
|
73
|
+
physics_spec = importlib.util.spec_from_file_location(name + "_physics", source_path())
|
|
74
|
+
physics = importlib.util.module_from_spec(physics_spec)
|
|
75
|
+
physics_spec.loader.exec_module(physics)
|
|
76
|
+
return physics, None
|
|
77
|
+
return sys.modules[name + ".physics"], sys.modules[name + ".integrators"]
|
|
78
|
+
finally:
|
|
79
|
+
sys.dont_write_bytecode = old_flag
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def rhs_reference(masses=MASSES, eps=EPS):
|
|
83
|
+
"""a_i = sum_j m_j (r_j - r_i) / (|r_j - r_i|^2 + eps^2)^1.5, written pair by pair."""
|
|
84
|
+
masses = np.asarray(masses, dtype=float)
|
|
85
|
+
|
|
86
|
+
def f(t, y):
|
|
87
|
+
r = y[:6].reshape(3, 2)
|
|
88
|
+
a = np.zeros((3, 2))
|
|
89
|
+
for i in range(3):
|
|
90
|
+
for j in range(i + 1, 3):
|
|
91
|
+
d = r[j] - r[i]
|
|
92
|
+
w = (d @ d + eps * eps) ** -1.5
|
|
93
|
+
a[i] += masses[j] * w * d
|
|
94
|
+
a[j] -= masses[i] * w * d
|
|
95
|
+
return np.concatenate([y[6:], a.ravel()])
|
|
96
|
+
|
|
97
|
+
return f
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def energy_reference(y, masses=MASSES, eps=EPS):
|
|
101
|
+
"""Energy of the softened system: the potential is -m_i m_j / sqrt(r^2 + eps^2)."""
|
|
102
|
+
r = y[:6].reshape(3, 2)
|
|
103
|
+
v = y[6:].reshape(3, 2)
|
|
104
|
+
e = 0.5 * float(np.sum(masses * np.sum(v * v, axis=1)))
|
|
105
|
+
for i in range(3):
|
|
106
|
+
for j in range(i + 1, 3):
|
|
107
|
+
d = r[j] - r[i]
|
|
108
|
+
e -= masses[i] * masses[j] / math.sqrt(d @ d + eps * eps)
|
|
109
|
+
return e
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def momentum(y, masses=MASSES):
|
|
113
|
+
return (masses[:, None] * y[6:].reshape(3, 2)).sum(axis=0)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def angular_momentum(y, masses=MASSES):
|
|
117
|
+
r = y[:6].reshape(3, 2)
|
|
118
|
+
v = y[6:].reshape(3, 2)
|
|
119
|
+
return float(np.sum(masses * (r[:, 0] * v[:, 1] - r[:, 1] * v[:, 0])))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def manufactured() -> ManufacturedODE:
|
|
123
|
+
def u(t):
|
|
124
|
+
pos = [np.cos(t), 0.3 * np.sin(t), -0.8 + 0.2 * np.sin(1.3 * t), 0.9, 0.1 * t, -1.0 + 0.4 * np.cos(t)]
|
|
125
|
+
vel = [np.sin(0.7 * t), 0.5, -0.3 + 0.1 * t, np.cos(1.1 * t), 0.2, -0.4 * np.sin(t)]
|
|
126
|
+
return np.array(pos + vel)
|
|
127
|
+
|
|
128
|
+
return ManufacturedODE(u, rhs_reference())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def verlet_integrate(integrators, masses=MASSES):
|
|
132
|
+
def integrate(source, y0, t_end, n):
|
|
133
|
+
if source is not zero_source:
|
|
134
|
+
raise ValueError("verlet_step has no source-term hook")
|
|
135
|
+
y = np.array(y0, dtype=float)
|
|
136
|
+
for _ in range(n):
|
|
137
|
+
y = integrators.verlet_step(y, masses, t_end / n)
|
|
138
|
+
return y
|
|
139
|
+
|
|
140
|
+
return integrate
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _exact_drift(label, integrate, quantity, ns, t_end=T_END, y0=Y0_FREE):
|
|
144
|
+
def run():
|
|
145
|
+
q0 = np.atleast_1d(quantity(y0))
|
|
146
|
+
hs, errs = [], []
|
|
147
|
+
for n in ns:
|
|
148
|
+
hs.append(t_end / n)
|
|
149
|
+
errs.append(float(np.max(np.abs(np.atleast_1d(quantity(integrate(zero_source, y0, t_end, n))) - q0))))
|
|
150
|
+
return check_exact(hs, errs, scale=max(float(np.max(np.abs(y0))), 1.0))
|
|
151
|
+
|
|
152
|
+
return Check("invariant", label, run)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def reversibility_check(integrate, ns=NS_VERLET) -> Check:
|
|
156
|
+
"""Velocity Verlet is time-symmetric: forward, flip velocities, forward again, flip: back to the start."""
|
|
157
|
+
|
|
158
|
+
def run():
|
|
159
|
+
hs, errs = [], []
|
|
160
|
+
for n in ns:
|
|
161
|
+
y = integrate(zero_source, Y0_FREE, T_END, n)
|
|
162
|
+
y = np.concatenate([y[:6], -y[6:]])
|
|
163
|
+
y = integrate(zero_source, y, T_END, n)
|
|
164
|
+
y = np.concatenate([y[:6], -y[6:]])
|
|
165
|
+
hs.append(T_END / n)
|
|
166
|
+
errs.append(max_abs_error(y, Y0_FREE))
|
|
167
|
+
return check_exact(hs, errs, floor=1e-11, scale=float(np.max(np.abs(Y0_FREE))))
|
|
168
|
+
|
|
169
|
+
return Check("patch", "verlet_step time reversal", run)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def binary_closure_check(integrators, ns=NS_VERLET) -> Check:
|
|
173
|
+
"""Two unit masses on a circular orbit of separation 1; the third body has zero mass.
|
|
174
|
+
|
|
175
|
+
Period T = 2 pi / sqrt(2) up to a softening correction of order eps^2 (about 1e-8),
|
|
176
|
+
far below the Verlet error at these steps. The orbit must close at second order.
|
|
177
|
+
"""
|
|
178
|
+
masses = np.array([1.0, 1.0, 0.0])
|
|
179
|
+
v = math.sqrt(0.5) * (1.0 + EPS * EPS) ** -0.75 # circular speed with softened force
|
|
180
|
+
y0 = np.array([-0.5, 0.0, 0.5, 0.0, 30.0, 0.0, 0.0, -v, 0.0, v, 0.0, 0.0])
|
|
181
|
+
omega = 2.0 * v # angular speed of each body about the centre
|
|
182
|
+
period = 2.0 * math.pi / omega
|
|
183
|
+
integrate = verlet_integrate(integrators, masses)
|
|
184
|
+
|
|
185
|
+
def run():
|
|
186
|
+
hs, errs = [], []
|
|
187
|
+
for n in ns:
|
|
188
|
+
y = integrate(zero_source, y0, period, n)
|
|
189
|
+
hs.append(period / n)
|
|
190
|
+
errs.append(max_abs_error(y[[0, 1, 2, 3, 6, 7, 8, 9]], y0[[0, 1, 2, 3, 6, 7, 8, 9]]))
|
|
191
|
+
return check_order(hs, errs, 2.0)
|
|
192
|
+
|
|
193
|
+
return Check("limit", "verlet_step two-body circular orbit closes after one period", run)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def driver_end_time_check(integrators, ns=NS_VERLET) -> Check:
|
|
197
|
+
"""run_verlet_simulation((0, T), dt) should return the state at T, at Verlet's second order."""
|
|
198
|
+
y_ref = integrate_fixed(rhs_reference(), Y0_FREE, 0.0, T_END, 16 * max(ns), rk4)
|
|
199
|
+
|
|
200
|
+
def run():
|
|
201
|
+
hs, errs, last = [], [], []
|
|
202
|
+
for n in ns:
|
|
203
|
+
times, states = integrators.run_verlet_simulation(Y0_FREE, MASSES, (0.0, T_END), T_END / n)
|
|
204
|
+
hs.append(T_END / n)
|
|
205
|
+
errs.append(max_abs_error(states[:, -1], y_ref))
|
|
206
|
+
last.append(float(times[-1]))
|
|
207
|
+
result = check_order(hs, errs, 2.0, scale=float(np.max(np.abs(y_ref))))
|
|
208
|
+
result.message += f"; last time returned {last[0]:.6g} for t_end = {T_END:g}, dt = {hs[0]:.3g}"
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
return Check("reference", "run_verlet_simulation final state at t_end", run)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def energy_function_check(physics) -> Check:
|
|
215
|
+
"""physics.calculate_energy against the softened energy that the dynamics conserves."""
|
|
216
|
+
|
|
217
|
+
def run():
|
|
218
|
+
e_code = float(physics.calculate_energy(Y0_FREE, MASSES)[0])
|
|
219
|
+
e_soft = energy_reference(Y0_FREE)
|
|
220
|
+
diff = abs(e_code - e_soft)
|
|
221
|
+
if diff <= 1e-12:
|
|
222
|
+
return Result(EXACT, f"matches the softened energy (diff {diff:.1e})")
|
|
223
|
+
return Result(
|
|
224
|
+
FAIL,
|
|
225
|
+
f"differs from the energy the softened dynamics conserves by {diff:.2g} "
|
|
226
|
+
f"(relative {diff / abs(e_soft):.1g}): potential is not softened",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return Check("invariant", "calculate_energy is the conserved quantity", run)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def solve_ivp_check(integrators, physics) -> Check:
|
|
233
|
+
def run():
|
|
234
|
+
t_end = 2.0
|
|
235
|
+
y_ref = integrate_fixed(rhs_reference(), Y0_FREE, 0.0, t_end, 20000, rk4)
|
|
236
|
+
sol = integrators.run_simulation(physics.get_derivatives, (0.0, t_end), Y0_FREE, MASSES)
|
|
237
|
+
err = max_abs_error(sol.y[:, -1], y_ref)
|
|
238
|
+
if err <= 1e-7:
|
|
239
|
+
return Result(PASS, f"default rtol=1e-9 reaches {err:.1e} at t = {t_end:g}")
|
|
240
|
+
return Result(FAIL, f"error {err:.3g} at default tolerances")
|
|
241
|
+
|
|
242
|
+
return Check("reference", "run_simulation (solve_ivp RK45) at default tolerances", run)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def checks(loaded=None) -> list[Check]:
|
|
246
|
+
physics, integrators = loaded or load()
|
|
247
|
+
|
|
248
|
+
def rhs(t, y):
|
|
249
|
+
return np.asarray(physics.get_derivatives(t, np.asarray(y, dtype=float), MASSES), dtype=float)
|
|
250
|
+
|
|
251
|
+
rk4_int = make_integrate(rhs, frozenset(), "rk4")
|
|
252
|
+
out = ode_checks(
|
|
253
|
+
"get_derivatives + physverify RK4",
|
|
254
|
+
rk4_int,
|
|
255
|
+
rhs_ref=rhs_reference(),
|
|
256
|
+
mms=manufactured(),
|
|
257
|
+
y0_free=Y0_FREE,
|
|
258
|
+
t_end=T_END,
|
|
259
|
+
ns=NS_RK4,
|
|
260
|
+
order=4.0,
|
|
261
|
+
quantity=energy_reference,
|
|
262
|
+
patch=(manufactured().value(0.0), np.array([0.1, -0.2, 0.3, 0.05, -0.1, 0.2] + [0.0] * 6)),
|
|
263
|
+
integrate_trajectory=make_integrate(rhs, frozenset(), "rk4", trajectory=True),
|
|
264
|
+
)
|
|
265
|
+
out.append(energy_function_check(physics))
|
|
266
|
+
if integrators is None:
|
|
267
|
+
return out
|
|
268
|
+
verlet = verlet_integrate(integrators)
|
|
269
|
+
out += [
|
|
270
|
+
Check("reference", "verlet_step", lambda: reference_study(verlet, rhs_reference(), Y0_FREE, T_END, NS_VERLET, 2.0)),
|
|
271
|
+
Check(
|
|
272
|
+
"invariant",
|
|
273
|
+
"verlet_step energy",
|
|
274
|
+
lambda: invariant_study(verlet, energy_reference, Y0_FREE, T_END, NS_VERLET, 2.0),
|
|
275
|
+
),
|
|
276
|
+
_exact_drift("verlet_step total momentum", verlet, momentum, NS_VERLET),
|
|
277
|
+
_exact_drift("verlet_step angular momentum", verlet, angular_momentum, NS_VERLET),
|
|
278
|
+
reversibility_check(verlet),
|
|
279
|
+
binary_closure_check(integrators),
|
|
280
|
+
driver_end_time_check(integrators),
|
|
281
|
+
solve_ivp_check(integrators, physics),
|
|
282
|
+
]
|
|
283
|
+
return out
|
physverify/agent.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Connect physverify to an AI coding assistant: a Claude Code hook and a minimal MCP server.
|
|
2
|
+
|
|
3
|
+
Both are thin layers over `physverify.report` (the same code as `physverify run`),
|
|
4
|
+
and the core works without them.
|
|
5
|
+
|
|
6
|
+
Hook (`physverify hook`)
|
|
7
|
+
For Claude Code's PostToolUse event. It reads the event JSON on stdin. After
|
|
8
|
+
an edit to a .py file it runs the project's specs, and when a check fails it
|
|
9
|
+
prints a summary on stderr and exits 2, which Claude Code passes back to the
|
|
10
|
+
assistant. Otherwise it exits 0 and prints nothing. See docs/agent.md.
|
|
11
|
+
|
|
12
|
+
MCP server (`physverify mcp`)
|
|
13
|
+
Model Context Protocol over stdio (newline-delimited JSON-RPC 2.0), with one
|
|
14
|
+
tool, `run_checks`, that takes spec paths and returns the JSON report. It is
|
|
15
|
+
written with the standard library only, so it has no extra dependency; it
|
|
16
|
+
implements the parts of the protocol a tool server needs, not the whole
|
|
17
|
+
specification. It serves both protocol eras on the same process:
|
|
18
|
+
- legacy (2025-11-25 and earlier): the client opens with `initialize`;
|
|
19
|
+
- modern (2026-07-28): no handshake, every request carries its protocol
|
|
20
|
+
version in `_meta`, and `server/discover` reports what the server supports.
|
|
21
|
+
A request that carries the modern `_meta` version is served the modern way; an
|
|
22
|
+
`initialize` request selects legacy behaviour.
|
|
23
|
+
|
|
24
|
+
Specs are found in this order: `[tool.physverify] specs = [...]` in the nearest
|
|
25
|
+
pyproject.toml, otherwise every verify_*.py under the project folder (skipping
|
|
26
|
+
hidden folders, virtual environments and build output).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
SKIP_DIRS = {"venv", ".venv", "env", "node_modules", "build", "dist", "__pycache__", "site-packages"}
|
|
36
|
+
MAX_REPORTED = 10
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_project_root(start: Path) -> Path:
|
|
40
|
+
start = start.resolve()
|
|
41
|
+
for folder in [start, *start.parents]:
|
|
42
|
+
if (folder / "pyproject.toml").is_file() or (folder / ".git").exists():
|
|
43
|
+
return folder
|
|
44
|
+
return start
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_specs(root: Path) -> list[Path]:
|
|
48
|
+
root = Path(root)
|
|
49
|
+
pyproject = root / "pyproject.toml"
|
|
50
|
+
if pyproject.is_file():
|
|
51
|
+
try:
|
|
52
|
+
import tomllib
|
|
53
|
+
|
|
54
|
+
config = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool", {}).get("physverify", {})
|
|
55
|
+
except Exception:
|
|
56
|
+
config = {}
|
|
57
|
+
if config.get("specs"):
|
|
58
|
+
return [(root / s).resolve() for s in config["specs"]]
|
|
59
|
+
specs = []
|
|
60
|
+
for path in sorted(root.rglob("verify_*.py")):
|
|
61
|
+
parts = path.relative_to(root).parts[:-1]
|
|
62
|
+
if any(p.startswith(".") or p in SKIP_DIRS for p in parts):
|
|
63
|
+
continue
|
|
64
|
+
specs.append(path)
|
|
65
|
+
return specs
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_specs(specs) -> dict:
|
|
69
|
+
"""Run every spec; returns the JSON report as a dict, with load errors listed separately.
|
|
70
|
+
|
|
71
|
+
Anything the code under test prints goes to stderr, so that it cannot corrupt
|
|
72
|
+
the MCP stream on stdout.
|
|
73
|
+
"""
|
|
74
|
+
import contextlib
|
|
75
|
+
|
|
76
|
+
from .report import load_spec, run_checks, to_json
|
|
77
|
+
|
|
78
|
+
outcomes, load_errors = [], []
|
|
79
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
80
|
+
for spec in specs:
|
|
81
|
+
try:
|
|
82
|
+
outcomes += run_checks(load_spec(spec))
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
load_errors.append({"spec": str(spec), "error": f"{type(exc).__name__}: {exc}"})
|
|
85
|
+
report = json.loads(to_json(outcomes, ", ".join(str(s) for s in specs)))
|
|
86
|
+
report["load_errors"] = load_errors
|
|
87
|
+
return report
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def failure_summary(report: dict) -> str:
|
|
91
|
+
failed = [c for c in report["checks"] if not c["ok"]]
|
|
92
|
+
lines = [f"physverify: {len(failed)} of {report['summary']['total']} checks did not pass."]
|
|
93
|
+
for c in failed[:MAX_REPORTED]:
|
|
94
|
+
lines.append(f"- {c['status']} [{c['family']}] {c['label']}: {c['message']}")
|
|
95
|
+
if len(failed) > MAX_REPORTED:
|
|
96
|
+
lines.append(f"- ... and {len(failed) - MAX_REPORTED} more")
|
|
97
|
+
for e in report["load_errors"]:
|
|
98
|
+
lines.append(f"- could not load {e['spec']}: {e['error']}")
|
|
99
|
+
lines.append("Run `physverify run <spec> --html report.html` for convergence plots.")
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def hook_main(stdin=None, stderr=None) -> int:
|
|
104
|
+
stdin = stdin or sys.stdin
|
|
105
|
+
stderr = stderr or sys.stderr
|
|
106
|
+
try:
|
|
107
|
+
event = json.load(stdin)
|
|
108
|
+
except (json.JSONDecodeError, ValueError):
|
|
109
|
+
return 0 # not a hook event; never block the assistant on our own input problem
|
|
110
|
+
tool_input = event.get("tool_input") or {}
|
|
111
|
+
edited = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
|
|
112
|
+
if edited and not edited.endswith(".py"):
|
|
113
|
+
return 0
|
|
114
|
+
cwd = Path(event.get("cwd") or ".")
|
|
115
|
+
root = find_project_root(Path(edited).parent if edited else cwd)
|
|
116
|
+
specs = find_specs(root)
|
|
117
|
+
if not specs:
|
|
118
|
+
return 0
|
|
119
|
+
report = run_specs(specs)
|
|
120
|
+
if report["summary"]["not_ok"] or report["load_errors"]:
|
|
121
|
+
print(failure_summary(report), file=stderr)
|
|
122
|
+
return 2
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- MCP server ---
|
|
127
|
+
|
|
128
|
+
MODERN_VERSION = "2026-07-28"
|
|
129
|
+
LEGACY_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05") # newest first
|
|
130
|
+
|
|
131
|
+
META_VERSION = "io.modelcontextprotocol/protocolVersion"
|
|
132
|
+
META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"
|
|
133
|
+
META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
|
|
134
|
+
|
|
135
|
+
INVALID_PARAMS = -32602
|
|
136
|
+
METHOD_NOT_FOUND = -32601
|
|
137
|
+
UNSUPPORTED_PROTOCOL_VERSION = -32022
|
|
138
|
+
|
|
139
|
+
CACHE_HINTS = {"ttlMs": 300_000, "cacheScope": "public"} # the tool list does not change while the server runs
|
|
140
|
+
INSTRUCTIONS = (
|
|
141
|
+
"Run physverify checks on physics simulation code after writing or editing it. "
|
|
142
|
+
"The tool executes the project's verify_*.py spec files (Python), so use it only on projects you trust."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
TOOLS = [
|
|
146
|
+
{
|
|
147
|
+
"name": "run_checks",
|
|
148
|
+
"description": (
|
|
149
|
+
"Run physverify verification checks (manufactured solutions, order of convergence, "
|
|
150
|
+
"invariants, limits, exactness) on physics simulation code. Takes spec files that "
|
|
151
|
+
"define checks(); with no specs given, finds verify_*.py files under `project`. "
|
|
152
|
+
"Returns a JSON report: per check family, label, status (PASS, EXACT, FAIL, "
|
|
153
|
+
"INCONCLUSIVE, ERROR), message, observed and expected order, step sizes and errors."
|
|
154
|
+
),
|
|
155
|
+
"inputSchema": {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"properties": {
|
|
158
|
+
"specs": {"type": "array", "items": {"type": "string"}, "description": "spec file paths"},
|
|
159
|
+
"project": {"type": "string", "description": "project folder to search for verify_*.py"},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _error(msg_id, code: int, message: str, data=None) -> dict:
|
|
167
|
+
error = {"code": code, "message": message}
|
|
168
|
+
if data is not None:
|
|
169
|
+
error["data"] = data
|
|
170
|
+
return {"jsonrpc": "2.0", "id": msg_id, "error": error}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _run_checks_tool(arguments: dict) -> dict:
|
|
174
|
+
"""The tools/call result body (content and isError), identical in both protocol eras."""
|
|
175
|
+
specs = [Path(s) for s in arguments.get("specs") or []]
|
|
176
|
+
if not specs:
|
|
177
|
+
specs = find_specs(Path(arguments.get("project") or "."))
|
|
178
|
+
if not specs:
|
|
179
|
+
return {"content": [{"type": "text", "text": "No spec files found (verify_*.py defining checks())."}], "isError": True}
|
|
180
|
+
report = run_specs(specs)
|
|
181
|
+
failed = bool(report["summary"]["not_ok"] or report["load_errors"])
|
|
182
|
+
text = (failure_summary(report) + "\n\n" if failed else "") + json.dumps(report, indent=2)
|
|
183
|
+
return {"content": [{"type": "text", "text": text}], "isError": False}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def handle(message: dict):
|
|
187
|
+
"""Answer one JSON-RPC message; returns the response dict, or None for a notification."""
|
|
188
|
+
from . import __version__
|
|
189
|
+
|
|
190
|
+
method = message.get("method")
|
|
191
|
+
msg_id = message.get("id")
|
|
192
|
+
if msg_id is None:
|
|
193
|
+
return None # notifications (initialized, cancelled, ...) need no answer
|
|
194
|
+
params = message.get("params") or {}
|
|
195
|
+
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
|
196
|
+
server_info = {"name": "physverify", "version": __version__}
|
|
197
|
+
|
|
198
|
+
def ok(result):
|
|
199
|
+
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
|
|
200
|
+
|
|
201
|
+
# Legacy era: the handshake. `initialize` always selects legacy behaviour.
|
|
202
|
+
if method == "initialize":
|
|
203
|
+
requested = params.get("protocolVersion")
|
|
204
|
+
version = requested if requested in LEGACY_VERSIONS else LEGACY_VERSIONS[0]
|
|
205
|
+
return ok({"protocolVersion": version, "capabilities": {"tools": {}}, "serverInfo": server_info})
|
|
206
|
+
|
|
207
|
+
# Modern era: no handshake; the request itself says which version it speaks.
|
|
208
|
+
# `server/discover` exists only in this era, so it is always treated as modern.
|
|
209
|
+
if META_VERSION in meta or method == "server/discover":
|
|
210
|
+
requested = meta.get(META_VERSION)
|
|
211
|
+
if requested is None or META_CAPABILITIES not in meta:
|
|
212
|
+
return _error(msg_id, INVALID_PARAMS, f"missing required _meta field: {META_VERSION!r} and {META_CAPABILITIES!r} are required on every request")
|
|
213
|
+
if requested != MODERN_VERSION:
|
|
214
|
+
return _error(msg_id, UNSUPPORTED_PROTOCOL_VERSION, "Unsupported protocol version", {"supported": [MODERN_VERSION], "requested": requested})
|
|
215
|
+
|
|
216
|
+
def modern(result, cacheable=False):
|
|
217
|
+
body = {"resultType": "complete", **result, "_meta": {META_SERVER_INFO: server_info}}
|
|
218
|
+
if cacheable:
|
|
219
|
+
body.update(CACHE_HINTS)
|
|
220
|
+
return ok(body)
|
|
221
|
+
|
|
222
|
+
if method == "server/discover":
|
|
223
|
+
return modern({"supportedVersions": [MODERN_VERSION], "capabilities": {"tools": {}}, "instructions": INSTRUCTIONS}, cacheable=True)
|
|
224
|
+
if method == "ping":
|
|
225
|
+
return modern({})
|
|
226
|
+
if method == "tools/list":
|
|
227
|
+
return modern({"tools": TOOLS}, cacheable=True)
|
|
228
|
+
if method == "tools/call":
|
|
229
|
+
if params.get("name") != "run_checks":
|
|
230
|
+
return _error(msg_id, INVALID_PARAMS, f"unknown tool {params.get('name')!r}")
|
|
231
|
+
return modern(_run_checks_tool(params.get("arguments") or {}))
|
|
232
|
+
return _error(msg_id, METHOD_NOT_FOUND, f"method not found: {method}")
|
|
233
|
+
|
|
234
|
+
# Legacy era, after the handshake: results carry no resultType or cache hints.
|
|
235
|
+
if method == "ping":
|
|
236
|
+
return ok({})
|
|
237
|
+
if method == "tools/list":
|
|
238
|
+
return ok({"tools": TOOLS})
|
|
239
|
+
if method == "tools/call":
|
|
240
|
+
if params.get("name") != "run_checks":
|
|
241
|
+
return _error(msg_id, INVALID_PARAMS, f"unknown tool {params.get('name')!r}")
|
|
242
|
+
return ok(_run_checks_tool(params.get("arguments") or {}))
|
|
243
|
+
return _error(msg_id, METHOD_NOT_FOUND, f"method not found: {method}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def mcp_main(stdin=None, stdout=None) -> int:
|
|
247
|
+
stdin = stdin or sys.stdin
|
|
248
|
+
stdout = stdout or sys.stdout
|
|
249
|
+
for line in stdin:
|
|
250
|
+
line = line.strip()
|
|
251
|
+
if not line:
|
|
252
|
+
continue
|
|
253
|
+
try:
|
|
254
|
+
message = json.loads(line)
|
|
255
|
+
except json.JSONDecodeError:
|
|
256
|
+
response = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}
|
|
257
|
+
else:
|
|
258
|
+
try:
|
|
259
|
+
response = handle(message)
|
|
260
|
+
except Exception as exc: # keep the server alive; report the failure to the client
|
|
261
|
+
response = {"jsonrpc": "2.0", "id": message.get("id"), "error": {"code": -32603, "message": f"{type(exc).__name__}: {exc}"}}
|
|
262
|
+
if response is not None:
|
|
263
|
+
stdout.write(json.dumps(response) + "\n")
|
|
264
|
+
stdout.flush()
|
|
265
|
+
return 0
|
physverify/checks.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Result types shared by every check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
PASS = "PASS"
|
|
11
|
+
FAIL = "FAIL"
|
|
12
|
+
EXACT = "EXACT" # error below the noise floor at every resolution
|
|
13
|
+
INCONCLUSIVE = "INCONCLUSIVE"
|
|
14
|
+
ERROR = "ERROR" # the code under test raised an exception
|
|
15
|
+
|
|
16
|
+
FAMILIES = ("mms", "reference", "invariant", "limit", "patch")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Result:
|
|
21
|
+
status: str
|
|
22
|
+
message: str = ""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def ok(self) -> bool:
|
|
26
|
+
return self.status in (PASS, EXACT)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class OrderResult(Result):
|
|
31
|
+
expected: float = float("nan")
|
|
32
|
+
fitted: float = float("nan")
|
|
33
|
+
hs: tuple = ()
|
|
34
|
+
errors: tuple = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Check:
|
|
39
|
+
"""One runnable check. `family` says which kind of evidence it gives:
|
|
40
|
+
|
|
41
|
+
mms manufactured-solution order study against an independent reference operator
|
|
42
|
+
reference comparison with an independently written reference solution
|
|
43
|
+
invariant drift of a conserved quantity as the resolution is refined
|
|
44
|
+
limit a known limiting case (analytic range, normal-mode frequencies, ...)
|
|
45
|
+
patch a manufactured solution the scheme should reproduce exactly (to round-off),
|
|
46
|
+
so any error at all is a bug, whatever its order
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
family: str
|
|
50
|
+
label: str
|
|
51
|
+
run: Callable[[], Result]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run_check(check: Check) -> Result:
|
|
55
|
+
try:
|
|
56
|
+
with np.errstate(all="ignore"): # a buggy code may overflow; the order check reports non-finite errors
|
|
57
|
+
return check.run()
|
|
58
|
+
except Exception as exc: # the code under test may crash; that is a finding too
|
|
59
|
+
return Result(ERROR, f"{type(exc).__name__}: {exc}")
|