physverify 0.6.0__tar.gz → 0.7.0__tar.gz
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-0.6.0/src/physverify.egg-info → physverify-0.7.0}/PKG-INFO +3 -3
- {physverify-0.6.0 → physverify-0.7.0}/README.md +2 -2
- {physverify-0.6.0 → physverify-0.7.0}/pyproject.toml +1 -1
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/__init__.py +1 -1
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/agent.py +24 -10
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/butcher.py +111 -3
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/cli.py +3 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/constants.py +4 -1
- physverify-0.7.0/src/physverify/fluxes.py +58 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/identities.py +111 -9
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/lint.py +64 -8
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/order.py +28 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/probes.py +10 -4
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/references.py +20 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/report.py +1 -1
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/scan.py +90 -3
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/smoke.py +3 -2
- physverify-0.7.0/src/physverify/specscan.py +67 -0
- physverify-0.7.0/src/physverify/stencils.py +69 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/stats.py +3 -1
- {physverify-0.6.0 → physverify-0.7.0/src/physverify.egg-info}/PKG-INFO +3 -3
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify.egg-info/SOURCES.txt +5 -0
- physverify-0.7.0/tests/test_awkward_input.py +151 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_batch8_lessons.py +18 -0
- physverify-0.7.0/tests/test_helpers_2026_10.py +146 -0
- physverify-0.7.0/tests/test_roadmap_0_7.py +83 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_scan.py +123 -0
- physverify-0.6.0/tests/test_awkward_input.py +0 -74
- {physverify-0.6.0 → physverify-0.7.0}/LICENSE +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/setup.cfg +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/__main__.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/checks.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/demo.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/loaders.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/mms.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/pytest_plugin.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/scaffold.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/steppers.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/__init__.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/calibrate.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/checks.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/exact.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/ising.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/reference.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/sampler.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/studies.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify/symbolic.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify.egg-info/dependency_links.txt +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify.egg-info/entry_points.txt +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify.egg-info/requires.txt +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/src/physverify.egg-info/top_level.txt +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_agent.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_batch7_lessons.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_butcher_properties.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_cli.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_corpus_lessons.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_docs_examples.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_figures.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_ising.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_method_bugs.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_mms.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_new_problems.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_order.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_probes.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_properties.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_real_projects.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_reference_problems.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_references.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_scaffold.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_seeded_bugs.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_studies.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_v040_helpers.py +0 -0
- {physverify-0.6.0 → physverify-0.7.0}/tests/test_versions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: physverify
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Verification checks for physics simulation code: manufactured solutions, order-of-accuracy studies and a seeded-bug benchmark.
|
|
5
5
|
Author: Ram Chandra Giri
|
|
6
6
|
License-Expression: MIT
|
|
@@ -46,7 +46,7 @@ Dynamic: license-file
|
|
|
46
46
|
|
|
47
47
|
**Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
|
|
48
48
|
|
|
49
|
-
physverify is actively evolving (v0.
|
|
49
|
+
physverify is actively evolving (v0.7.0), welcoming contributions and test cases from the computational physics community. Status: under active development and maintenance, tested on a growing corpus of public physics codes; releases come at milestones. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
|
|
50
50
|
|
|
51
51
|

|
|
52
52
|
|
|
@@ -73,7 +73,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
|
|
|
73
73
|
A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
|
|
74
74
|
|
|
75
75
|
```yaml
|
|
76
|
-
- uses: rcgiri-physics/physverify@v0.
|
|
76
|
+
- uses: rcgiri-physics/physverify@v0.7.0
|
|
77
77
|
with:
|
|
78
78
|
specs: tests/verify_*.py
|
|
79
79
|
```
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
|
|
6
6
|
|
|
7
|
-
physverify is actively evolving (v0.
|
|
7
|
+
physverify is actively evolving (v0.7.0), welcoming contributions and test cases from the computational physics community. Status: under active development and maintenance, tested on a growing corpus of public physics codes; releases come at milestones. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
@@ -31,7 +31,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
|
|
|
31
31
|
A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
|
|
32
32
|
|
|
33
33
|
```yaml
|
|
34
|
-
- uses: rcgiri-physics/physverify@v0.
|
|
34
|
+
- uses: rcgiri-physics/physverify@v0.7.0
|
|
35
35
|
with:
|
|
36
36
|
specs: tests/verify_*.py
|
|
37
37
|
```
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "physverify"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Verification checks for physics simulation code: manufactured solutions, order-of-accuracy studies and a seeded-bug benchmark."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -112,14 +112,18 @@ def hook_main(stdin=None, stderr=None) -> int:
|
|
|
112
112
|
event = json.load(stdin)
|
|
113
113
|
except (json.JSONDecodeError, ValueError):
|
|
114
114
|
return 0 # not a hook event; never block the assistant on our own input problem
|
|
115
|
-
|
|
115
|
+
if not isinstance(event, dict):
|
|
116
|
+
return 0
|
|
117
|
+
tool_input = event.get("tool_input")
|
|
118
|
+
tool_input = tool_input if isinstance(tool_input, dict) else {}
|
|
116
119
|
edited = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
|
|
117
|
-
if edited and not edited.endswith(".py"):
|
|
120
|
+
if not isinstance(edited, str) or (edited and not edited.endswith(".py")):
|
|
118
121
|
return 0
|
|
119
122
|
cwd = Path(event.get("cwd") or ".").resolve()
|
|
120
|
-
|
|
123
|
+
edited_path = (cwd / edited).resolve() if edited else None # a relative path is relative to the session folder
|
|
124
|
+
if edited_path is not None and not edited_path.is_relative_to(cwd):
|
|
121
125
|
return 0 # an edit outside the session folder must not run that folder's code (GCVE-110-VULN-PYPI-2026-000391)
|
|
122
|
-
root = find_project_root(
|
|
126
|
+
root = find_project_root(edited_path.parent if edited_path is not None else cwd)
|
|
123
127
|
if not root.is_relative_to(cwd):
|
|
124
128
|
root = cwd # never climb above the session folder
|
|
125
129
|
specs = find_specs(root)
|
|
@@ -141,8 +145,11 @@ META_VERSION = "io.modelcontextprotocol/protocolVersion"
|
|
|
141
145
|
META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"
|
|
142
146
|
META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
|
|
143
147
|
|
|
144
|
-
|
|
148
|
+
PARSE_ERROR = -32700
|
|
149
|
+
INVALID_REQUEST = -32600
|
|
145
150
|
METHOD_NOT_FOUND = -32601
|
|
151
|
+
INVALID_PARAMS = -32602
|
|
152
|
+
INTERNAL_ERROR = -32603
|
|
146
153
|
UNSUPPORTED_PROTOCOL_VERSION = -32022
|
|
147
154
|
|
|
148
155
|
CACHE_HINTS = {"ttlMs": 300_000, "cacheScope": "public"} # the tool list does not change while the server runs
|
|
@@ -252,6 +259,16 @@ def handle(message: dict):
|
|
|
252
259
|
return _error(msg_id, METHOD_NOT_FOUND, f"method not found: {method}")
|
|
253
260
|
|
|
254
261
|
|
|
262
|
+
def _respond(message):
|
|
263
|
+
"""handle(), but a message that is not an object, or an exception, becomes an error response; the server keeps running."""
|
|
264
|
+
if not isinstance(message, dict): # a batch, a number, a string: not a request this server answers
|
|
265
|
+
return _error(None, INVALID_REQUEST, "invalid request: expected a JSON object")
|
|
266
|
+
try:
|
|
267
|
+
return handle(message)
|
|
268
|
+
except Exception as exc: # report the failure to the client
|
|
269
|
+
return _error(message.get("id"), INTERNAL_ERROR, f"{type(exc).__name__}: {exc}")
|
|
270
|
+
|
|
271
|
+
|
|
255
272
|
def mcp_main(stdin=None, stdout=None) -> int:
|
|
256
273
|
stdin = stdin or sys.stdin
|
|
257
274
|
stdout = stdout or sys.stdout
|
|
@@ -262,12 +279,9 @@ def mcp_main(stdin=None, stdout=None) -> int:
|
|
|
262
279
|
try:
|
|
263
280
|
message = json.loads(line)
|
|
264
281
|
except json.JSONDecodeError:
|
|
265
|
-
response =
|
|
282
|
+
response = _error(None, PARSE_ERROR, "parse error")
|
|
266
283
|
else:
|
|
267
|
-
|
|
268
|
-
response = handle(message)
|
|
269
|
-
except Exception as exc: # keep the server alive; report the failure to the client
|
|
270
|
-
response = {"jsonrpc": "2.0", "id": message.get("id"), "error": {"code": -32603, "message": f"{type(exc).__name__}: {exc}"}}
|
|
284
|
+
response = _respond(message)
|
|
271
285
|
if response is not None:
|
|
272
286
|
stdout.write(json.dumps(response) + "\n")
|
|
273
287
|
stdout.flush()
|
|
@@ -186,6 +186,7 @@ def a_stability_margin(A, b) -> float:
|
|
|
186
186
|
iy = np.polynomial.Polynomial([0, 1j])
|
|
187
187
|
Pi = np.polynomial.Polynomial(P.astype(complex))(iy)
|
|
188
188
|
Qi = np.polynomial.Polynomial(Q.astype(complex))(iy)
|
|
189
|
+
|
|
189
190
|
def sq(p): # |p(iy)|^2 for real y, as a polynomial in y
|
|
190
191
|
return p * np.polynomial.Polynomial(np.conj(p.coef))
|
|
191
192
|
|
|
@@ -205,9 +206,39 @@ def is_stiffly_accurate(A, b, *, tol: float = 1e-12) -> bool:
|
|
|
205
206
|
return bool(np.max(np.abs(np.asarray(A, float)[-1] - np.asarray(b, float))) <= tol)
|
|
206
207
|
|
|
207
208
|
|
|
209
|
+
def square_tableau(A, b, c=None):
|
|
210
|
+
"""(A, b, c) with A made square: a table stored as s x (s - 1) (the last column of an explicit table is
|
|
211
|
+
zero and is often left out) or with an extra FSAL stage that has weight 0 in b is padded with zeros.
|
|
212
|
+
Explicit FSAL tables (Dormand-Prince, Bogacki-Shampine) then check like any other."""
|
|
213
|
+
A = np.atleast_2d(np.asarray(A, dtype=float))
|
|
214
|
+
b = np.asarray(b, dtype=float)
|
|
215
|
+
n = max(A.shape[0], A.shape[1], b.size)
|
|
216
|
+
full = np.zeros((n, n))
|
|
217
|
+
full[:A.shape[0], :A.shape[1]] = A
|
|
218
|
+
bb = np.zeros(n)
|
|
219
|
+
bb[:b.size] = b
|
|
220
|
+
if c is not None:
|
|
221
|
+
cc = np.zeros(n)
|
|
222
|
+
cc[:np.size(c)] = np.asarray(c, dtype=float)
|
|
223
|
+
if np.size(c) < n:
|
|
224
|
+
cc[np.size(c):] = full[np.size(c):].sum(axis=1)
|
|
225
|
+
c = cc
|
|
226
|
+
return full, bb, c
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def symplectic_residual(A, b) -> float:
|
|
230
|
+
"""max |b_i a_ij + b_j a_ji - b_i b_j|: zero exactly for a symplectic Runge-Kutta method (Sanz-Serna 1988;
|
|
231
|
+
Hairer, Lubich and Wanner, Geometric Numerical Integration, Thm. VI.4.3). Gauss methods are symplectic;
|
|
232
|
+
no explicit Runge-Kutta method is."""
|
|
233
|
+
A, b, _ = square_tableau(A, b)
|
|
234
|
+
M = b[:, None] * A + (b[:, None] * A).T - np.outer(b, b)
|
|
235
|
+
return float(np.max(np.abs(M)))
|
|
236
|
+
|
|
237
|
+
|
|
208
238
|
def check_tableau(A, b, c=None, *, claimed: int, label: str = "", tol: float = 1e-8, max_order: int | None = None,
|
|
209
239
|
claimed_linear: int | None = None, claimed_stage: int | None = None, a_stable: bool | None = None,
|
|
210
|
-
l_stable: bool | None = None, stiffly_accurate: bool | None = None
|
|
240
|
+
l_stable: bool | None = None, stiffly_accurate: bool | None = None, embedded=None,
|
|
241
|
+
claimed_embedded: int | None = None, symplectic: bool | None = None) -> Result:
|
|
211
242
|
"""PASS when the table has at least the claimed order and every other stated property; FAIL otherwise.
|
|
212
243
|
|
|
213
244
|
The default tolerance 1e-8 accepts coefficients typed with 9 or 10 digits (a real package had
|
|
@@ -222,8 +253,13 @@ def check_tableau(A, b, c=None, *, claimed: int, label: str = "", tol: float = 1
|
|
|
222
253
|
a_stable |R(z)| <= 1 on Re z <= 0; see `a_stability_margin`
|
|
223
254
|
l_stable A-stable and R(inf) = 0
|
|
224
255
|
stiffly_accurate b equals the last row of A
|
|
256
|
+
symplectic b_i a_ij + b_j a_ji = b_i b_j; see `symplectic_residual`
|
|
225
257
|
True means "the method has it" and False means "the method does not"; a mismatch either way fails.
|
|
258
|
+
embedded the weights of the embedded (error-estimate) method; with `claimed_embedded`, its order
|
|
259
|
+
is checked too, and the error weights b - embedded must sum to 0.
|
|
260
|
+
A table stored as s x (s - 1), or with an FSAL stage of weight 0, is padded first (`square_tableau`).
|
|
226
261
|
"""
|
|
262
|
+
A, b, c = square_tableau(A, b, c)
|
|
227
263
|
top = max_order or claimed + 1
|
|
228
264
|
res = order_residuals(A, b, top)
|
|
229
265
|
p = tableau_order(A, b, max_order=top, tol=tol)
|
|
@@ -258,8 +294,8 @@ def check_tableau(A, b, c=None, *, claimed: int, label: str = "", tol: float = 1
|
|
|
258
294
|
notes.append(f"linear order is at least {pl} (claimed {claimed_linear})")
|
|
259
295
|
else:
|
|
260
296
|
notes.append(f"linear order {pl} confirmed")
|
|
261
|
-
elif linear_order(A, b, max_order=max(p, claimed) + 2, tol=tol) > p:
|
|
262
|
-
notes.append(f"linear order {
|
|
297
|
+
elif (pl := linear_order(A, b, max_order=max(p, claimed) + 2, tol=tol)) > p:
|
|
298
|
+
notes.append(f"linear order {pl} is higher than the order {p}:"
|
|
263
299
|
" a convergence test on y' = lambda*y alone would overstate the order")
|
|
264
300
|
if claimed_stage is not None:
|
|
265
301
|
q = stage_order(A, c, b, max_order=claimed_stage + 1, tol=tol)
|
|
@@ -291,6 +327,78 @@ def check_tableau(A, b, c=None, *, claimed: int, label: str = "", tol: float = 1
|
|
|
291
327
|
else:
|
|
292
328
|
notes.append(f"{'' if has else 'not '}stiffly accurate as stated")
|
|
293
329
|
|
|
330
|
+
if embedded is not None:
|
|
331
|
+
Ae = A
|
|
332
|
+
if np.size(embedded) > A.shape[0]:
|
|
333
|
+
# error weights over one more stage (SciPy's E): the FSAL stage, evaluated at y_{n+1}, has row b
|
|
334
|
+
Ae = np.vstack([np.hstack([A, np.zeros((A.shape[0], 1))]), np.append(b, 0.0)])
|
|
335
|
+
Ae, be, _ = square_tableau(Ae, embedded)
|
|
336
|
+
if abs(be.sum() - 1) > tol:
|
|
337
|
+
fails.append(f"embedded weights sum to {be.sum():.10g}, not 1 (so b - embedded does not sum to 0)")
|
|
338
|
+
if claimed_embedded is not None:
|
|
339
|
+
pe = tableau_order(Ae, be, max_order=claimed_embedded + 1, tol=tol)
|
|
340
|
+
if pe < claimed_embedded:
|
|
341
|
+
fails.append(f"claims embedded order {claimed_embedded} but the embedded weights satisfy the order"
|
|
342
|
+
f" conditions only up to {pe}")
|
|
343
|
+
else:
|
|
344
|
+
notes.append(f"embedded order {pe}" + (f" (claimed {claimed_embedded})" if pe > claimed_embedded else " confirmed"))
|
|
345
|
+
if symplectic is not None:
|
|
346
|
+
r = symplectic_residual(A, b)
|
|
347
|
+
has = r <= tol
|
|
348
|
+
if has != symplectic:
|
|
349
|
+
fails.append(f"stated {'' if symplectic else 'not '}symplectic, but max |b_i a_ij + b_j a_ji - b_i b_j| = {r:.1e}")
|
|
350
|
+
else:
|
|
351
|
+
notes.append(f"{'' if has else 'not '}symplectic as stated")
|
|
352
|
+
|
|
294
353
|
if fails:
|
|
295
354
|
return Result(FAIL, prefix + "; ".join(fails + notes))
|
|
296
355
|
return Result(PASS, prefix + "; ".join(notes))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# Published tables, for comparing a code's copy against them (exact rationals as floats).
|
|
359
|
+
_S3 = np.sqrt(3.0)
|
|
360
|
+
TABLES = {
|
|
361
|
+
"rk4": dict(A=[[0, 0, 0, 0], [0.5, 0, 0, 0], [0, 0.5, 0, 0], [0, 0, 1, 0]], b=[1 / 6, 1 / 3, 1 / 3, 1 / 6],
|
|
362
|
+
c=[0, 0.5, 0.5, 1], order=4),
|
|
363
|
+
# Bogacki and Shampine 1989; SciPy's RK23 (embedded order 2, FSAL)
|
|
364
|
+
"bogacki_shampine_3_2": dict(A=[[0, 0, 0, 0], [1 / 2, 0, 0, 0], [0, 3 / 4, 0, 0], [2 / 9, 1 / 3, 4 / 9, 0]],
|
|
365
|
+
b=[2 / 9, 1 / 3, 4 / 9, 0], c=[0, 1 / 2, 3 / 4, 1], order=3,
|
|
366
|
+
embedded=[7 / 24, 1 / 4, 1 / 3, 1 / 8], embedded_order=2),
|
|
367
|
+
# Dormand and Prince 1980; SciPy's RK45 and MATLAB's ode45 (embedded order 4, FSAL)
|
|
368
|
+
"dormand_prince_5_4": dict(
|
|
369
|
+
A=[[0, 0, 0, 0, 0, 0, 0],
|
|
370
|
+
[1 / 5, 0, 0, 0, 0, 0, 0],
|
|
371
|
+
[3 / 40, 9 / 40, 0, 0, 0, 0, 0],
|
|
372
|
+
[44 / 45, -56 / 15, 32 / 9, 0, 0, 0, 0],
|
|
373
|
+
[19372 / 6561, -25360 / 2187, 64448 / 6561, -212 / 729, 0, 0, 0],
|
|
374
|
+
[9017 / 3168, -355 / 33, 46732 / 5247, 49 / 176, -5103 / 18656, 0, 0],
|
|
375
|
+
[35 / 384, 0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84, 0]],
|
|
376
|
+
b=[35 / 384, 0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84, 0], c=[0, 1 / 5, 3 / 10, 4 / 5, 8 / 9, 1, 1],
|
|
377
|
+
order=5, embedded=[5179 / 57600, 0, 7571 / 16695, 393 / 640, -92097 / 339200, 187 / 2100, 1 / 40],
|
|
378
|
+
embedded_order=4),
|
|
379
|
+
"implicit_midpoint": dict(A=[[0.5]], b=[1.0], c=[0.5], order=2),
|
|
380
|
+
"gauss_legendre_4": dict(A=[[1 / 4, 1 / 4 - _S3 / 6], [1 / 4 + _S3 / 6, 1 / 4]], b=[0.5, 0.5],
|
|
381
|
+
c=[0.5 - _S3 / 6, 0.5 + _S3 / 6], order=4),
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _drop_fsal(A, b, tol: float = 1e-14):
|
|
386
|
+
"""Without a last FSAL stage (row equal to b, weight 0 in b): codes store such tables with or without it."""
|
|
387
|
+
if A.shape[0] > 1 and abs(b[-1]) <= tol and np.allclose(A[-1, :-1], b[:-1], atol=tol, rtol=0):
|
|
388
|
+
return A[:-1, :-1], b[:-1]
|
|
389
|
+
return A, b
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def compare_tableau(A, b, name: str, *, tol: float = 1e-10) -> Result:
|
|
393
|
+
"""A code's copy of a published table (one of `TABLES`) has the published coefficients."""
|
|
394
|
+
ref = TABLES[name]
|
|
395
|
+
A1, b1 = _drop_fsal(*square_tableau(A, b)[:2])
|
|
396
|
+
A2, b2 = _drop_fsal(*square_tableau(ref["A"], ref["b"])[:2])
|
|
397
|
+
if A1.shape != A2.shape:
|
|
398
|
+
return Result(FAIL, f"{name}: {A1.shape[0]} stages, the published table has {A2.shape[0]}")
|
|
399
|
+
dA, db = np.abs(A1 - A2), np.abs(b1 - b2)
|
|
400
|
+
if max(dA.max(), db.max()) <= tol:
|
|
401
|
+
return Result(PASS, f"{name}: coefficients match the published table to {tol:g}")
|
|
402
|
+
i, j = np.unravel_index(int(np.argmax(dA)), dA.shape)
|
|
403
|
+
where = f"A[{i}][{j}] off by {dA[i, j]:.3g}" if dA.max() >= db.max() else f"b[{int(np.argmax(db))}] off by {db.max():.3g}"
|
|
404
|
+
return Result(FAIL, f"{name}: coefficients differ from the published table, largest at {where}")
|
|
@@ -61,6 +61,9 @@ def _cmd_run(args) -> int:
|
|
|
61
61
|
print(f"physverify: {spec}: checks() returned no checks, so nothing was tested", file=sys.stderr)
|
|
62
62
|
return 2
|
|
63
63
|
outcomes += run_checks(checks, args.budget)
|
|
64
|
+
from .specscan import reference_notes
|
|
65
|
+
for note in reference_notes(spec):
|
|
66
|
+
print(f"physverify: note: {note}", file=sys.stderr)
|
|
64
67
|
source = ", ".join(str(Path(s)) for s in args.specs)
|
|
65
68
|
if args.json == "-":
|
|
66
69
|
print(to_json(outcomes, source))
|
|
@@ -129,7 +129,10 @@ def scan(path, *, max_rel: float = 0.1) -> list[Note]:
|
|
|
129
129
|
notes = []
|
|
130
130
|
for node in ast.walk(tree):
|
|
131
131
|
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
|
|
132
|
-
|
|
132
|
+
try:
|
|
133
|
+
v = float(node.value)
|
|
134
|
+
except OverflowError: # an integer literal too large for a float is no physical constant
|
|
135
|
+
continue
|
|
133
136
|
hit = check_value(abs(v), max_rel=max_rel)
|
|
134
137
|
if hit and (isinstance(node.value, int) or _digits(v) < 3):
|
|
135
138
|
if not names.get(id(node), set()) & NAMES.get(hit[0], set()):
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Exactness properties every numerical flux of a conservation law must have (Toro, Riemann Solvers and
|
|
2
|
+
Numerical Methods for Fluid Dynamics, 3rd ed., ch. 10-11; LeVeque, Finite Volume Methods, sec. 12.1):
|
|
3
|
+
|
|
4
|
+
- consistency F(W, W) = f(W), the physical flux, for any state W
|
|
5
|
+
- upwinding when every wave speed is positive on both sides, F(WL, WR) = f(WL) (all negative: f(WR));
|
|
6
|
+
Roe, HLL, HLLC and Rusanov fluxes with exact wave-speed bounds satisfy it
|
|
7
|
+
- mirror reflecting the normal velocity and swapping the sides gives F(R WR, R WL) = -R F(WL, WR),
|
|
8
|
+
with R the reflection acting on the flux components
|
|
9
|
+
|
|
10
|
+
Each needs only the flux function and the physical flux written from the equations, not a solver run.
|
|
11
|
+
In the 2026-10 corpus sessions these checks found both bugs in one Euler solver: a Roe flux that broke
|
|
12
|
+
upwinding when density and tangential velocity both jumped, and an HLLE flux built for the wrong fluid.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Callable, Iterable
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .checks import FAIL, PASS, Result
|
|
22
|
+
from .identities import _verdict
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_flux(flux: Callable, physical: Callable, states: Iterable, *, upwind_pairs: Iterable = (),
|
|
26
|
+
downwind_pairs: Iterable = (), mirror: tuple | None = None, rtol: float = 1e-10,
|
|
27
|
+
atol: float = 1e-12) -> Result:
|
|
28
|
+
"""Consistency, upwinding and (optionally) mirror symmetry of a numerical flux `flux(WL, WR)`.
|
|
29
|
+
|
|
30
|
+
`physical(W)` is the exact flux written from the equations; `states` are the states W for the
|
|
31
|
+
consistency check. `upwind_pairs` are (WL, WR) with all wave speeds positive on both sides (F must equal
|
|
32
|
+
f(WL)) and `downwind_pairs` all negative (F must equal f(WR)); the caller builds them from the sound
|
|
33
|
+
speed. `mirror = (reflect, signs)`: `reflect(W)` flips the normal velocity of a state and `signs` holds
|
|
34
|
+
+1 or -1 per flux component (for the 1D Euler equations (-1, +1, -1): mass and energy fluxes change sign,
|
|
35
|
+
the momentum flux does not); the check is F(reflect(WR), reflect(WL)) = signs * F(WL, WR), over all pairs.
|
|
36
|
+
"""
|
|
37
|
+
states = list(states)
|
|
38
|
+
if not states:
|
|
39
|
+
raise ValueError("no states given")
|
|
40
|
+
up, down = list(upwind_pairs), list(downwind_pairs)
|
|
41
|
+
results = [("consistency F(W, W) = f(W)",
|
|
42
|
+
_verdict("consistency", [(f"state {i}", flux(w, w), physical(w)) for i, w in enumerate(states)], rtol, atol))]
|
|
43
|
+
if up:
|
|
44
|
+
results.append(("upwinding F = f(WL) for flow to the right",
|
|
45
|
+
_verdict("upwinding", [(f"pair {i}", flux(a, b), physical(a)) for i, (a, b) in enumerate(up)], rtol, atol)))
|
|
46
|
+
if down:
|
|
47
|
+
results.append(("upwinding F = f(WR) for flow to the left",
|
|
48
|
+
_verdict("upwinding", [(f"pair {i}", flux(a, b), physical(b)) for i, (a, b) in enumerate(down)], rtol, atol)))
|
|
49
|
+
if mirror is not None:
|
|
50
|
+
reflect, signs = mirror
|
|
51
|
+
signs = np.asarray(signs, dtype=float)
|
|
52
|
+
pairs = up + down + list(zip(states, states[1:]))
|
|
53
|
+
cases = [(f"pair {i}", flux(reflect(b), reflect(a)), signs * np.asarray(flux(a, b))) for i, (a, b) in enumerate(pairs)]
|
|
54
|
+
results.append(("mirror symmetry", _verdict("mirror symmetry", cases, rtol, atol)))
|
|
55
|
+
failed = [f"{name}: {r.message}" for name, r in results if not r.ok]
|
|
56
|
+
if failed:
|
|
57
|
+
return Result(FAIL, "; ".join(failed))
|
|
58
|
+
return Result(PASS, "the numerical flux passes " + ", ".join(name for name, _r in results))
|
|
@@ -22,7 +22,11 @@ from .checks import FAIL, PASS, Result
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def _worst(got, ref, rtol: float, atol: float):
|
|
25
|
-
"""Largest violation of |got - ref| <= atol + rtol |ref|, as (excess, abs error, where).
|
|
25
|
+
"""Largest violation of |got - ref| <= atol + rtol |ref|, as (excess, abs error, where, got there, ref there).
|
|
26
|
+
|
|
27
|
+
`where` indexes the broadcast shape, so the values there are returned too: indexing the original
|
|
28
|
+
`got` with it fails when a scalar was compared with an array.
|
|
29
|
+
"""
|
|
26
30
|
g = np.asarray(got)
|
|
27
31
|
r = np.asarray(ref)
|
|
28
32
|
if g.shape != r.shape:
|
|
@@ -34,12 +38,12 @@ def _worst(got, ref, rtol: float, atol: float):
|
|
|
34
38
|
tol = atol + rtol * np.abs(r)
|
|
35
39
|
excess = err - tol
|
|
36
40
|
if excess.size == 0:
|
|
37
|
-
return -np.inf, 0.0, ()
|
|
41
|
+
return -np.inf, 0.0, (), got, ref
|
|
38
42
|
if not np.all(np.isfinite(err)):
|
|
39
43
|
i = np.unravel_index(int(np.argmax(~np.isfinite(err))), err.shape)
|
|
40
|
-
return np.inf, np.inf, i
|
|
44
|
+
return np.inf, np.inf, i, (g[i] if i else got), (r[i] if i else ref)
|
|
41
45
|
i = np.unravel_index(int(np.argmax(excess)), excess.shape)
|
|
42
|
-
return float(excess[i]), float(err[i]), i
|
|
46
|
+
return float(excess[i]), float(err[i]), i, (g[i] if i else got), (r[i] if i else ref)
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
def _verdict(what: str, cases, rtol: float, atol: float) -> Result:
|
|
@@ -48,18 +52,16 @@ def _verdict(what: str, cases, rtol: float, atol: float) -> Result:
|
|
|
48
52
|
n = 0
|
|
49
53
|
for label, got, ref in cases:
|
|
50
54
|
n += 1
|
|
51
|
-
excess, err, where = _worst(got, ref, rtol, atol)
|
|
55
|
+
excess, err, where, g, r = _worst(got, ref, rtol, atol)
|
|
52
56
|
if worst is None or excess > worst[0]:
|
|
53
|
-
worst = (excess, err, label, where,
|
|
57
|
+
worst = (excess, err, label, where, g, r)
|
|
54
58
|
if worst is None:
|
|
55
59
|
raise ValueError(f"{what}: no inputs given")
|
|
56
|
-
excess, err, label, where,
|
|
60
|
+
excess, err, label, where, g, r = worst
|
|
57
61
|
at = f" at index {tuple(int(k) for k in where)}" if where else ""
|
|
58
62
|
detail = f"worst case {label}{at}: |difference| = {err:.3g} (rtol {rtol:g}, atol {atol:g})"
|
|
59
63
|
if excess <= 0:
|
|
60
64
|
return Result(PASS, f"{what}: {n} case{'s' if n != 1 else ''} within tolerance; {detail}")
|
|
61
|
-
g = np.asarray(got)[where] if where else got
|
|
62
|
-
r = np.asarray(ref)[where] if where else ref
|
|
63
65
|
return Result(FAIL, f"{what}: {detail}; got {g!r}, expected {r!r}")
|
|
64
66
|
|
|
65
67
|
|
|
@@ -155,3 +157,103 @@ def check_unitary(matrix, *, atol: float = 1e-10) -> Result:
|
|
|
155
157
|
eye = np.eye(m.shape[-1])
|
|
156
158
|
prod = np.einsum("...ji,...jk->...ik", m.conj(), m)
|
|
157
159
|
return _verdict("unitarity M^H M = I", [("matrix", prod, np.broadcast_to(eye, prod.shape))], 0.0, atol)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _passes(outcome, rtol: float, atol: float) -> tuple[bool, str]:
|
|
163
|
+
"""A test outcome as (passed, detail): a Result, a bool, or a (got, ref) pair compared within tolerance."""
|
|
164
|
+
if isinstance(outcome, Result):
|
|
165
|
+
return outcome.ok, outcome.message
|
|
166
|
+
if isinstance(outcome, tuple) and len(outcome) == 2:
|
|
167
|
+
excess, err, _where, _g, _r = _worst(outcome[0], outcome[1], rtol, atol)
|
|
168
|
+
return excess <= 0, f"|difference| = {err:.3g}"
|
|
169
|
+
return bool(outcome), ""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def vary_one(test: Callable, defaults: dict, changes: dict, *, rtol: float = 1e-9, atol: float = 0.0) -> Result:
|
|
173
|
+
"""Re-run a test that passes at the default parameters with one parameter changed at a time.
|
|
174
|
+
|
|
175
|
+
`test(**params)` returns a Result, a bool, or a (got, ref) pair with the reference from the physics.
|
|
176
|
+
`defaults` are the usual values (often symmetric ones: Lx = Ly, nx = ny, zero boundary data, s polarisation,
|
|
177
|
+
vacuum); `changes` maps a parameter to a value, or a list of values, to try instead while the others keep
|
|
178
|
+
their defaults. FAIL names each change that breaks the test.
|
|
179
|
+
|
|
180
|
+
In the 2026-10 corpus sessions 7 of the confirmed bugs passed every check at the example values and
|
|
181
|
+
appeared only when one parameter left them: a y-derivative scaled with Lx (only when Lx != Ly), a
|
|
182
|
+
Neumann row that dropped the boundary value (only when g != 0), the p-polarisation path of a grating
|
|
183
|
+
solver, an inertia formula right for rectangles only, a factor that cancels for low-loss media.
|
|
184
|
+
"""
|
|
185
|
+
ok, detail = _passes(test(**defaults), rtol, atol)
|
|
186
|
+
if not ok:
|
|
187
|
+
return Result(FAIL, f"fails already at the defaults {defaults}" + (f": {detail}" if detail else ""))
|
|
188
|
+
broken, tried = [], 0
|
|
189
|
+
for name, values in changes.items():
|
|
190
|
+
if name not in defaults:
|
|
191
|
+
raise ValueError(f"{name} is not one of the default parameters")
|
|
192
|
+
for v in values if isinstance(values, (list, tuple)) else [values]:
|
|
193
|
+
tried += 1
|
|
194
|
+
try:
|
|
195
|
+
ok, detail = _passes(test(**{**defaults, name: v}), rtol, atol)
|
|
196
|
+
except Exception as exc: # noqa: BLE001 - an exception fails that change, not the helper
|
|
197
|
+
ok, detail = False, f"{type(exc).__name__}: {exc}"
|
|
198
|
+
if not ok:
|
|
199
|
+
broken.append(f"{name} = {v!r}" + (f" ({detail})" if detail else ""))
|
|
200
|
+
if broken:
|
|
201
|
+
return Result(FAIL, f"passes at the defaults but fails when one parameter changes: {'; '.join(broken)}")
|
|
202
|
+
return Result(PASS, f"passes at the defaults and with each of {tried} one-parameter changes ({', '.join(changes)})")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_FACTORS = {"2": 2.0, "1/2": 0.5, "pi": np.pi, "1/pi": 1 / np.pi, "2 pi": 2 * np.pi, "1/(2 pi)": 1 / (2 * np.pi),
|
|
206
|
+
"sqrt(2)": np.sqrt(2), "1/sqrt(2)": 1 / np.sqrt(2), "4 pi": 4 * np.pi, "1/(4 pi)": 1 / (4 * np.pi),
|
|
207
|
+
"-1": -1.0, "4": 4.0, "1/4": 0.25, "3": 3.0, "1/3": 1 / 3}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def check_scale(got, ref, *, rtol: float = 1e-6, min_correlation: float = 0.999) -> Result:
|
|
211
|
+
"""The output has the right size, not only the right shape: got = k * ref with k = 1.
|
|
212
|
+
|
|
213
|
+
Fits the single factor k (least squares) and the correlation of got with ref. A correlation near 1
|
|
214
|
+
with k != 1 is a scale or units error (a missing 1/(2 dx), a missing time step, a factor 1/2); the
|
|
215
|
+
message names a nearby simple factor (2, pi, sqrt 2, ...) or gives k to match against a grid spacing
|
|
216
|
+
or a sampling rate. In the 2026-10 corpus sessions the ratio alone pointed to the bug in four
|
|
217
|
+
repositories. A low correlation means the shape itself is wrong, and a plain comparison says more.
|
|
218
|
+
"""
|
|
219
|
+
g = np.ravel(np.asarray(got, dtype=complex))
|
|
220
|
+
r = np.ravel(np.asarray(ref, dtype=complex))
|
|
221
|
+
if g.shape != r.shape:
|
|
222
|
+
raise ValueError(f"shapes differ: got {np.shape(got)}, reference {np.shape(ref)}")
|
|
223
|
+
rr = np.vdot(r, r).real
|
|
224
|
+
if rr == 0:
|
|
225
|
+
raise ValueError("the reference is zero everywhere, so no scale can be fitted")
|
|
226
|
+
k = np.vdot(r, g) / rr
|
|
227
|
+
gg = np.vdot(g, g).real
|
|
228
|
+
corr = abs(np.vdot(r, g)) / np.sqrt(rr * gg) if gg > 0 else 0.0
|
|
229
|
+
kshow = f"{k.real:.6g}" if abs(k.imag) <= 1e-12 * abs(k) else f"{k:.6g}"
|
|
230
|
+
if corr < min_correlation:
|
|
231
|
+
return Result(FAIL, f"output and reference differ in shape, not only in scale (correlation {corr:.4f},"
|
|
232
|
+
f" best factor {kshow}); compare them directly")
|
|
233
|
+
if abs(k - 1) <= rtol:
|
|
234
|
+
return Result(PASS, f"output = {kshow} x reference (correlation {corr:.6f})")
|
|
235
|
+
near = [n for n, v in _FACTORS.items() if abs(k - v) <= 1e-3 * abs(v)]
|
|
236
|
+
hint = f", which is {near[0]}" if near else ""
|
|
237
|
+
return Result(FAIL, f"output = {kshow} x reference{hint} (correlation {corr:.6f}): the shape is right but the scale"
|
|
238
|
+
" is not; look for a missing factor (grid spacing, time step, sampling rate, 2, pi) or a units slip")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def check_vec_convention(superop, f: Callable, x, *, expected: str | None = None, atol: float = 1e-10) -> Result:
|
|
242
|
+
"""Which vectorisation a superoperator follows: superop @ vec(X) == vec(f(X)), with vec in C (row-major)
|
|
243
|
+
or F (column-major) order.
|
|
244
|
+
|
|
245
|
+
Kronecker constructions of Liouvillians and other superoperators depend on the convention
|
|
246
|
+
(vec(A X B) = (B^T kron A) vec(X) holds for column stacking); code that builds S for one and reshapes
|
|
247
|
+
with the other gives a wrong but well-formed answer (corpus repo L204). With `expected` ('C' or 'F'),
|
|
248
|
+
FAIL unless S follows it; without, PASS when S follows either, and say which.
|
|
249
|
+
"""
|
|
250
|
+
S = np.asarray(superop)
|
|
251
|
+
X = np.asarray(x)
|
|
252
|
+
want = np.asarray(f(X))
|
|
253
|
+
match = [o for o in ("C", "F") if np.allclose(S @ X.ravel(order=o), want.ravel(order=o), atol=atol, rtol=0)]
|
|
254
|
+
if not match:
|
|
255
|
+
return Result(FAIL, "the superoperator matches f(X) in neither C (row-major) nor F (column-major) vectorisation")
|
|
256
|
+
which = " and ".join(match)
|
|
257
|
+
if expected is not None and expected.upper() not in match:
|
|
258
|
+
return Result(FAIL, f"the superoperator follows {which}-order vectorisation, not the expected {expected.upper()}")
|
|
259
|
+
return Result(PASS, f"the superoperator follows {which}-order vectorisation (row-major is C, column-major is F)")
|