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,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}")