physverify 0.5.0__tar.gz → 0.5.1__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.5.0/src/physverify.egg-info → physverify-0.5.1}/PKG-INFO +5 -3
- {physverify-0.5.0 → physverify-0.5.1}/README.md +4 -2
- {physverify-0.5.0 → physverify-0.5.1}/pyproject.toml +1 -1
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/__init__.py +1 -1
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/agent.py +8 -2
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/constants.py +3 -1
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/lint.py +18 -3
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/scan.py +52 -4
- {physverify-0.5.0 → physverify-0.5.1/src/physverify.egg-info}/PKG-INFO +5 -3
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_agent.py +16 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_scan.py +71 -0
- {physverify-0.5.0 → physverify-0.5.1}/LICENSE +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/setup.cfg +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/__main__.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/butcher.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/checks.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/cli.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/demo.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/identities.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/loaders.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/mms.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/order.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/pytest_plugin.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/references.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/report.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/scaffold.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/smoke.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/steppers.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/__init__.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/calibrate.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/checks.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/exact.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/ising.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/reference.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/sampler.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/stats.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/studies.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify/symbolic.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify.egg-info/SOURCES.txt +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify.egg-info/dependency_links.txt +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify.egg-info/entry_points.txt +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify.egg-info/requires.txt +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/src/physverify.egg-info/top_level.txt +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_awkward_input.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_batch7_lessons.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_batch8_lessons.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_butcher_properties.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_cli.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_corpus_lessons.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_docs_examples.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_figures.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_ising.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_method_bugs.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_mms.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_new_problems.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_order.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_properties.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_real_projects.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_reference_problems.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_references.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_scaffold.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_seeded_bugs.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_studies.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_v040_helpers.py +0 -0
- {physverify-0.5.0 → physverify-0.5.1}/tests/test_versions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: physverify
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
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
|
|
@@ -45,7 +45,7 @@ Dynamic: license-file
|
|
|
45
45
|
|
|
46
46
|
**Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
|
|
47
47
|
|
|
48
|
-
physverify is actively evolving (v0.5.
|
|
48
|
+
physverify is actively evolving (v0.5.1), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
|
|
49
49
|
|
|
50
50
|

|
|
51
51
|
|
|
@@ -72,7 +72,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
|
|
|
72
72
|
A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
|
|
73
73
|
|
|
74
74
|
```yaml
|
|
75
|
-
- uses: rcgiri-physics/physverify@v0.5.
|
|
75
|
+
- uses: rcgiri-physics/physverify@v0.5.1
|
|
76
76
|
with:
|
|
77
77
|
specs: tests/verify_*.py
|
|
78
78
|
```
|
|
@@ -90,3 +90,5 @@ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a sour
|
|
|
90
90
|
## Contributing, citing, licence
|
|
91
91
|
|
|
92
92
|
Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md), the [code of conduct](https://github.com/rcgiri-physics/physverify/blob/main/CODE_OF_CONDUCT.md), [SUPPORT.md](https://github.com/rcgiri-physics/physverify/blob/main/SUPPORT.md) for help, [SECURITY.md](https://github.com/rcgiri-physics/physverify/blob/main/SECURITY.md) on running untrusted code, and [AGENTS.md](https://github.com/rcgiri-physics/physverify/blob/main/AGENTS.md) for AI assistants. To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). MIT licence, copyright (c) 2026 Ram Chandra Giri.
|
|
93
|
+
|
|
94
|
+
<!-- mcp-name: io.github.rcgiri-physics/physverify -->
|
|
@@ -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.5.
|
|
7
|
+
physverify is actively evolving (v0.5.1), welcoming contributions and test cases from the computational physics community. 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.5.
|
|
34
|
+
- uses: rcgiri-physics/physverify@v0.5.1
|
|
35
35
|
with:
|
|
36
36
|
specs: tests/verify_*.py
|
|
37
37
|
```
|
|
@@ -49,3 +49,5 @@ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a sour
|
|
|
49
49
|
## Contributing, citing, licence
|
|
50
50
|
|
|
51
51
|
Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md), the [code of conduct](https://github.com/rcgiri-physics/physverify/blob/main/CODE_OF_CONDUCT.md), [SUPPORT.md](https://github.com/rcgiri-physics/physverify/blob/main/SUPPORT.md) for help, [SECURITY.md](https://github.com/rcgiri-physics/physverify/blob/main/SECURITY.md) on running untrusted code, and [AGENTS.md](https://github.com/rcgiri-physics/physverify/blob/main/AGENTS.md) for AI assistants. To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). MIT licence, copyright (c) 2026 Ram Chandra Giri.
|
|
52
|
+
|
|
53
|
+
<!-- mcp-name: io.github.rcgiri-physics/physverify -->
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "physverify"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.1"
|
|
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"
|
|
@@ -55,7 +55,9 @@ def find_specs(root: Path) -> list[Path]:
|
|
|
55
55
|
except Exception:
|
|
56
56
|
config = {}
|
|
57
57
|
if config.get("specs"):
|
|
58
|
-
|
|
58
|
+
# Entries that leave the root are dropped (GCVE-110-VULN-PYPI-2026-000391).
|
|
59
|
+
inside = root.resolve()
|
|
60
|
+
return [p for p in ((root / s).resolve() for s in config["specs"]) if p.is_relative_to(inside)]
|
|
59
61
|
specs = []
|
|
60
62
|
for path in sorted(root.rglob("verify_*.py")):
|
|
61
63
|
parts = path.relative_to(root).parts[:-1]
|
|
@@ -111,8 +113,12 @@ def hook_main(stdin=None, stderr=None) -> int:
|
|
|
111
113
|
edited = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
|
|
112
114
|
if edited and not edited.endswith(".py"):
|
|
113
115
|
return 0
|
|
114
|
-
cwd = Path(event.get("cwd") or ".")
|
|
116
|
+
cwd = Path(event.get("cwd") or ".").resolve()
|
|
117
|
+
if edited and not Path(edited).resolve().is_relative_to(cwd):
|
|
118
|
+
return 0 # an edit outside the session folder must not run that folder's code (GCVE-110-VULN-PYPI-2026-000391)
|
|
115
119
|
root = find_project_root(Path(edited).parent if edited else cwd)
|
|
120
|
+
if not root.is_relative_to(cwd):
|
|
121
|
+
root = cwd # never climb above the session folder
|
|
116
122
|
specs = find_specs(root)
|
|
117
123
|
if not specs:
|
|
118
124
|
return 0
|
|
@@ -32,11 +32,13 @@ KNOWN = {
|
|
|
32
32
|
"e (charge)": ((1.602176634e-19,), "C", "exact: 1.602176634e-19"),
|
|
33
33
|
"m_e": ((9.1093837015e-31, 9.1093837139e-31), "kg", "CODATA 2018: 9.1093837015e-31, 2022: 9.1093837139e-31"),
|
|
34
34
|
"m_p": ((1.67262192369e-27, 1.67262192595e-27), "kg", "CODATA 2018: 1.67262192369e-27, 2022: 1.67262192595e-27"),
|
|
35
|
+
"m_n": ((1.67492749804e-27, 1.67492750056e-27), "kg", "CODATA 2018: 1.67492749804e-27, 2022: 1.67492750056e-27"),
|
|
36
|
+
"atomic mass unit": ((1.66053906660e-27, 1.66053906892e-27), "kg", "CODATA 2018: 1.66053906660e-27, 2022: 1.66053906892e-27"),
|
|
35
37
|
"epsilon_0": ((8.8541878128e-12,), "F/m", "8.8541878128e-12"),
|
|
36
38
|
"T_c (2D Ising)": ((2 / math.log(1 + math.sqrt(2)),), "J/k_B", "2 / ln(1 + sqrt 2) = 2.269185314"),
|
|
37
39
|
# exact by definition (SI 2019, IAU 2012, international units)
|
|
38
40
|
"N_A": ((6.02214076e23,), "1/mol", "exact: 6.02214076e23"),
|
|
39
|
-
"R (gas constant)": ((8.
|
|
41
|
+
"R (gas constant)": ((8.31446261815324,), "J/(mol K)", "exact: N_A k_B = 8.31446261815324"),
|
|
40
42
|
"sigma (Stefan-Boltzmann)": ((5.670374419e-8,), "W m^-2 K^-4", "5.670374419e-8"),
|
|
41
43
|
"astronomical unit": ((149597870700.0, 149597870.7, 1.495978707e13), "m, km or cm", "exact: 149597870700 m (IAU 2012)"),
|
|
42
44
|
"light year": ((9460730472580800.0,), "m", "exact: 9460730472580800 m (Julian year)"),
|
|
@@ -129,6 +129,10 @@ def _annotation_nodes(tree) -> set[int]:
|
|
|
129
129
|
return {id(m) for r in roots for m in ast.walk(r)}
|
|
130
130
|
|
|
131
131
|
|
|
132
|
+
# a file with this many undefined names, none of them a usual module alias, is treated as getting them at run time
|
|
133
|
+
_INJECTED_MIN = 6
|
|
134
|
+
_COMMON_ALIASES = {"np", "plt", "pd", "sp", "scipy", "numpy", "math", "os", "sys", "re", "json", "time", "random",
|
|
135
|
+
"torch", "jnp", "jax", "tf", "mpl", "sns", "nx", "integrate", "interpolate", "optimize", "linalg"}
|
|
132
136
|
_NOQA = re.compile(r"#\s*noqa(?::[^#]*\bF821\b|\s*$|\s*#)", re.I)
|
|
133
137
|
|
|
134
138
|
|
|
@@ -155,6 +159,14 @@ def scan_names(path: Path, tree, rel: str, source: str = "") -> list[Finding]:
|
|
|
155
159
|
continue # annotations are not evaluated with `from __future__ import annotations` (or from Python 3.14)
|
|
156
160
|
unknown.setdefault(n.id, []).append(n.lineno)
|
|
157
161
|
out = []
|
|
162
|
+
if len(unknown) >= _INJECTED_MIN and not unknown.keys() & _COMMON_ALIASES:
|
|
163
|
+
# many names and none of them a forgotten import: the file most likely gets them at run time
|
|
164
|
+
# (a host program such as SpaceClaim, or a library that defines names in the caller's namespace)
|
|
165
|
+
first = min(lines[0] for lines in unknown.values())
|
|
166
|
+
shown = ", ".join(sorted(unknown)[:8]) + (", ..." if len(unknown) > 8 else "")
|
|
167
|
+
return [Finding("names", NOTE, rel, first,
|
|
168
|
+
f"{len(unknown)} names are used but never defined or imported ({shown}); so many usually means they"
|
|
169
|
+
" are provided at run time by a host program or a library, so they are not reported one by one")]
|
|
158
170
|
for name, lines in sorted(unknown.items(), key=lambda kv: kv[1][0]):
|
|
159
171
|
close = [k for k in known if k != name and len(k) > 2 and _one_edit(name, k)]
|
|
160
172
|
hint = f" (did you mean {close[0]}?)" if close else ""
|
|
@@ -225,7 +237,8 @@ _SCIPY = {
|
|
|
225
237
|
"cumtrapz": "scipy.integrate.cumulative_trapezoid", "romb": None},
|
|
226
238
|
"scipy.misc": {"*": "scipy.datasets, scipy.special or numpy (scipy.misc was removed)"},
|
|
227
239
|
}
|
|
228
|
-
|
|
240
|
+
_SHIMMED = {"distutils"} # still importable wherever setuptools is installed, so only a NOTE
|
|
241
|
+
_PY_REMOVED = {"imp": "importlib (removed in Python 3.12)", "distutils": "setuptools or sysconfig (removed from the standard library in Python 3.12; setuptools still provides it)",
|
|
229
242
|
"asynchat": "asyncio (removed in Python 3.12)", "asyncore": "asyncio (removed in Python 3.12)",
|
|
230
243
|
"smtpd": "aiosmtpd (removed in Python 3.12)"}
|
|
231
244
|
|
|
@@ -281,12 +294,14 @@ def scan_api(path: Path, tree, rel: str) -> list[Finding]:
|
|
|
281
294
|
out.append(Finding("api", WARN, rel, n.lineno, f"{n.module}.{a.name} has been removed from SciPy"
|
|
282
295
|
+ (f"; use {new}" if new else "")))
|
|
283
296
|
if n.module.split(".")[0] in _PY_REMOVED:
|
|
284
|
-
|
|
297
|
+
top = n.module.split(".")[0]
|
|
298
|
+
out.append(Finding("api", NOTE if top in _SHIMMED else WARN, rel, n.lineno, f"{n.module}: use {_PY_REMOVED[top]}"))
|
|
285
299
|
if isinstance(n, ast.Import):
|
|
286
300
|
for a in n.names:
|
|
287
301
|
top = a.name.split(".")[0]
|
|
288
302
|
if top in _PY_REMOVED:
|
|
289
|
-
out.append(Finding("api", WARN, rel, n.lineno,
|
|
303
|
+
out.append(Finding("api", NOTE if top in _SHIMMED else WARN, rel, n.lineno,
|
|
304
|
+
f"import {a.name}: use {_PY_REMOVED[top]}"))
|
|
290
305
|
if a.name == "scipy.misc":
|
|
291
306
|
out.append(Finding("api", WARN, rel, n.lineno, "scipy.misc has been removed from SciPy"))
|
|
292
307
|
if isinstance(n, ast.Attribute) and n.attr in ("simps", "cumtrapz") and isinstance(n.value, ast.Attribute) \
|
|
@@ -26,7 +26,7 @@ import ast
|
|
|
26
26
|
import math
|
|
27
27
|
import re
|
|
28
28
|
import warnings
|
|
29
|
-
from dataclasses import asdict, dataclass
|
|
29
|
+
from dataclasses import asdict, dataclass, field
|
|
30
30
|
from fractions import Fraction
|
|
31
31
|
from pathlib import Path
|
|
32
32
|
|
|
@@ -42,6 +42,7 @@ class Finding:
|
|
|
42
42
|
path: str
|
|
43
43
|
line: int
|
|
44
44
|
message: str
|
|
45
|
+
table: dict | None = field(default=None, repr=False) # the Runge-Kutta table a "tables" finding was made from
|
|
45
46
|
|
|
46
47
|
def __str__(self):
|
|
47
48
|
return f"{self.level} {self.stage:9s} {self.path}:{self.line}: {self.message}"
|
|
@@ -364,7 +365,14 @@ def _check_table(lits, name, doc, stem, stated, rel, *, kind="table", stated_emb
|
|
|
364
365
|
msgs.append(f"embedded weights {en} have order {pe}, but order {stated_embedded} is stated")
|
|
365
366
|
else:
|
|
366
367
|
msgs.append(f"embedded weights {en} have order {pe}")
|
|
367
|
-
|
|
368
|
+
table = {"name": name, "kind": kind, "A": A, "b": b, "c": lits[cn][0] if cn is not None else None,
|
|
369
|
+
"claimed": claimed if claimed is not None and claimed <= 6 else None, "claim_source": why,
|
|
370
|
+
"stated_embedded": stated_embedded,
|
|
371
|
+
"embedded": {en: e for en, (e, _l) in lits.items() if _BHAT_NAMES.match(en) and _vector(e, s) and en != bn},
|
|
372
|
+
"error_weights": {en: e for en, (e, _l) in lits.items()
|
|
373
|
+
if (_E_NAMES.match(en) or en == "<e>") and _vector(e, s) and en != bn},
|
|
374
|
+
"order": p}
|
|
375
|
+
found.append(Finding("tables", level, rel, line, f"{where}: " + "; ".join(msgs), table))
|
|
368
376
|
return found
|
|
369
377
|
|
|
370
378
|
|
|
@@ -745,7 +753,11 @@ def scan_code_tables(path: Path, tree, rel: str) -> list[Finding]:
|
|
|
745
753
|
msgs.append(f"but '{why}' suggests order {claimed} (largest residual {worst:.1e})")
|
|
746
754
|
else:
|
|
747
755
|
msgs.append(f"consistent with '{why}'")
|
|
748
|
-
|
|
756
|
+
table = {"name": fn.name, "kind": "stages written as code", "A": A, "b": b, "c": c,
|
|
757
|
+
"claimed": claimed if claimed is not None and claimed <= 6 else None, "claim_source": why,
|
|
758
|
+
"stated_embedded": None, "embedded": {}, "error_weights": {}, "order": p,
|
|
759
|
+
"docstring_table": {"A": drawn[0], "b": drawn[1]} if drawn is not None and len(drawn[1]) == s else None}
|
|
760
|
+
found.append(Finding("tables", level, rel, line, f"{fn.name}: " + "; ".join(msgs), table))
|
|
749
761
|
return found
|
|
750
762
|
|
|
751
763
|
|
|
@@ -850,6 +862,7 @@ def _own_grids_and_divs(scope):
|
|
|
850
862
|
nodes = list(_own_nodes(scope))
|
|
851
863
|
# a division used as the step of a slice (np.mgrid[a:b:(b-a)/n], arange-style) builds its own grid
|
|
852
864
|
steps = {id(n.step) for n in nodes if isinstance(n, ast.Slice) and n.step is not None}
|
|
865
|
+
periodic = [] # linspace(a, b, M, endpoint=False): its spacing really is (b - a) / M
|
|
853
866
|
for node in nodes:
|
|
854
867
|
if id(node) in steps:
|
|
855
868
|
continue
|
|
@@ -858,12 +871,17 @@ def _own_grids_and_divs(scope):
|
|
|
858
871
|
if fn == "linspace" and len(node.args) >= 3:
|
|
859
872
|
if any(k.arg == "endpoint" and isinstance(k.value, ast.Constant) and k.value.value is False
|
|
860
873
|
for k in node.keywords):
|
|
874
|
+
periodic.append(node.args[2])
|
|
861
875
|
continue
|
|
862
876
|
grids.append((node.args[0], node.args[1], node.args[2], node.lineno))
|
|
863
877
|
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
|
|
864
878
|
den_names = _names(node.right)
|
|
865
879
|
if den_names: # x / 2 cannot be a grid spacing with a named number of points
|
|
866
880
|
divs.append((node, node.lineno, den_names))
|
|
881
|
+
if periodic:
|
|
882
|
+
# a spacing L / M next to its own periodic grid of M points is right; do not pair it with grids elsewhere
|
|
883
|
+
divs = [d for d in divs if not any(_src(d[0].right) == _src(m) or _src(d[0].right) == f"({_src(m)})"
|
|
884
|
+
for m in periodic)]
|
|
867
885
|
return grids, divs
|
|
868
886
|
|
|
869
887
|
|
|
@@ -913,7 +931,9 @@ _PAIR = re.compile(r"^(?P<pre>.*?)(?P<x>[a-z0-9]+)(?P<sep>_to_|2)(?P<y>[a-z0-9]+
|
|
|
913
931
|
|
|
914
932
|
|
|
915
933
|
def scan_pairs(path: Path, tree, rel: str) -> list[Finding]:
|
|
916
|
-
|
|
934
|
+
# test_x_to_y / test_y_to_x test an inverse pair; they are not converters to run
|
|
935
|
+
funcs = {n.name: n.lineno for n in all_nodes(tree)
|
|
936
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and not n.name.startswith("test")}
|
|
917
937
|
found, seen = [], set()
|
|
918
938
|
for fname, line in funcs.items():
|
|
919
939
|
m = _PAIR.match(fname)
|
|
@@ -934,6 +954,11 @@ def scan_pairs(path: Path, tree, rel: str) -> list[Finding]:
|
|
|
934
954
|
# them only when they are close; dimensional constants (6.6e-11, 3e8) rarely match by chance.
|
|
935
955
|
_PURE = {"pi", "e (Euler)", "T_c (2D Ising)", "G M_sun / 4 pi^2 (au, yr)"}
|
|
936
956
|
_PURE_REL = 2e-3
|
|
957
|
+
# Constants with units: an old edition or a rounded value is within 0.1%, while values 2-10% away were almost all
|
|
958
|
+
# other quantities in the corpus rescan (test values, the atomic mass unit next to the proton mass).
|
|
959
|
+
_DIM_REL = 1e-2
|
|
960
|
+
_TABLE_MIN = 8
|
|
961
|
+
_MANY_CONSTANTS = 20
|
|
937
962
|
# Units and constants fixed by definition: a slip or an old value sits within 0.1%, so only that close is noted.
|
|
938
963
|
_DEFINED = {"N_A", "R (gas constant)", "sigma (Stefan-Boltzmann)", "astronomical unit", "light year", "parsec",
|
|
939
964
|
"standard atmosphere", "torr", "nautical mile", "h (CGS)", "k_B (CGS)", "c (CGS)", "e (esu)", "m_e (g)",
|
|
@@ -946,10 +971,15 @@ def scan_constants(path: Path, tree, rel: str) -> list[Finding]:
|
|
|
946
971
|
from . import constants
|
|
947
972
|
|
|
948
973
|
names = constants._assigned_names(tree)
|
|
974
|
+
# numbers in long literal tables are data (measurements, reference values), not constants typed by hand
|
|
975
|
+
tabulated = {id(e) for n in all_nodes(tree) if isinstance(n, (ast.List, ast.Tuple)) and len(n.elts) >= _TABLE_MIN
|
|
976
|
+
for e in n.elts}
|
|
949
977
|
hits = {}
|
|
950
978
|
for node in all_nodes(tree):
|
|
951
979
|
if not (isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool)):
|
|
952
980
|
continue
|
|
981
|
+
if id(node) in tabulated:
|
|
982
|
+
continue
|
|
953
983
|
try:
|
|
954
984
|
v = float(node.value)
|
|
955
985
|
except OverflowError:
|
|
@@ -965,9 +995,16 @@ def scan_constants(path: Path, tree, rel: str) -> list[Finding]:
|
|
|
965
995
|
continue
|
|
966
996
|
elif name in _PURE and abs(r) > _PURE_REL:
|
|
967
997
|
continue
|
|
998
|
+
elif name not in _PURE and abs(r) > _DIM_REL:
|
|
999
|
+
continue # 1.44e-19 is a test value, not a mistyped elementary charge
|
|
968
1000
|
if name in _DEFINED and abs(r) > (_DEFINED_REL / 10 if isinstance(node.value, int) else _DEFINED_REL):
|
|
969
1001
|
continue # a definition typed wrong or taken from an old edition is close; 14666785333344 is not an au
|
|
970
1002
|
hits.setdefault((name, ast.unparse(node)), []).append((node.lineno, ref, r, comment))
|
|
1003
|
+
if len(hits) > _MANY_CONSTANTS:
|
|
1004
|
+
first = min(w[0][0] for w in hits.values())
|
|
1005
|
+
return [Finding("constants", NOTE, rel, first,
|
|
1006
|
+
f"{len(hits)} different numbers in this file are close to physical constants; so many usually means"
|
|
1007
|
+
" the file holds data (fits, tables, generated code), so they are not listed one by one")]
|
|
971
1008
|
out = []
|
|
972
1009
|
for (name, literal), where in sorted(hits.items(), key=lambda kv: kv[1][0][0]):
|
|
973
1010
|
line, ref, r, comment = where[0]
|
|
@@ -1032,6 +1069,17 @@ def scan(path, *, stages=STAGES) -> tuple[list[Finding], list[str]]:
|
|
|
1032
1069
|
return findings, unparsed
|
|
1033
1070
|
|
|
1034
1071
|
|
|
1072
|
+
def extract_tables(path) -> list[dict]:
|
|
1073
|
+
"""Every Runge-Kutta table the scan finds under `path`, as dicts with A, b, c (or None), the claimed order and
|
|
1074
|
+
where the claim comes from, embedded and error weights, and the order the coefficients have. Runs nothing.
|
|
1075
|
+
|
|
1076
|
+
For checks the scan does not make itself, pass them on, e.g.
|
|
1077
|
+
`physverify.butcher.check_tableau(t["A"], t["b"], t["c"], claimed=t["claimed"])`.
|
|
1078
|
+
"""
|
|
1079
|
+
findings, _unparsed = scan(path, stages=("tables",))
|
|
1080
|
+
return [dict(f.table, path=f.path, line=f.line, level=f.level) for f in findings if f.table is not None]
|
|
1081
|
+
|
|
1082
|
+
|
|
1035
1083
|
def to_dict(findings, unparsed) -> dict:
|
|
1036
1084
|
return {"findings": [asdict(f) for f in findings], "unparsed": unparsed,
|
|
1037
1085
|
"warn": sum(f.level == WARN for f in findings), "note": sum(f.level == NOTE for f in findings)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: physverify
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
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
|
|
@@ -45,7 +45,7 @@ Dynamic: license-file
|
|
|
45
45
|
|
|
46
46
|
**Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
|
|
47
47
|
|
|
48
|
-
physverify is actively evolving (v0.5.
|
|
48
|
+
physverify is actively evolving (v0.5.1), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
|
|
49
49
|
|
|
50
50
|

|
|
51
51
|
|
|
@@ -72,7 +72,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
|
|
|
72
72
|
A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
|
|
73
73
|
|
|
74
74
|
```yaml
|
|
75
|
-
- uses: rcgiri-physics/physverify@v0.5.
|
|
75
|
+
- uses: rcgiri-physics/physverify@v0.5.1
|
|
76
76
|
with:
|
|
77
77
|
specs: tests/verify_*.py
|
|
78
78
|
```
|
|
@@ -90,3 +90,5 @@ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a sour
|
|
|
90
90
|
## Contributing, citing, licence
|
|
91
91
|
|
|
92
92
|
Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md), the [code of conduct](https://github.com/rcgiri-physics/physverify/blob/main/CODE_OF_CONDUCT.md), [SUPPORT.md](https://github.com/rcgiri-physics/physverify/blob/main/SUPPORT.md) for help, [SECURITY.md](https://github.com/rcgiri-physics/physverify/blob/main/SECURITY.md) on running untrusted code, and [AGENTS.md](https://github.com/rcgiri-physics/physverify/blob/main/AGENTS.md) for AI assistants. To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). MIT licence, copyright (c) 2026 Ram Chandra Giri.
|
|
93
|
+
|
|
94
|
+
<!-- mcp-name: io.github.rcgiri-physics/physverify -->
|
|
@@ -68,6 +68,22 @@ def test_hook_ignores_non_python_edits_and_bad_input(project):
|
|
|
68
68
|
assert hook_main(io.StringIO("not json"), io.StringIO()) == 0
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def test_hook_does_not_run_code_outside_the_session_folder(project, tmp_path_factory):
|
|
72
|
+
session = tmp_path_factory.mktemp("session")
|
|
73
|
+
seed_bug(project)
|
|
74
|
+
edit = io.StringIO(json.dumps({"cwd": str(session),
|
|
75
|
+
"tool_input": {"file_path": str(project / "damped_oscillator.py")}}))
|
|
76
|
+
assert hook_main(edit, io.StringIO()) == 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_pyproject_specs_outside_the_root_are_dropped(project, tmp_path_factory):
|
|
80
|
+
outside = tmp_path_factory.mktemp("outside") / "verify_evil.py"
|
|
81
|
+
outside.write_text("raise SystemExit('must not be imported')\n")
|
|
82
|
+
(project / "pyproject.toml").write_text(
|
|
83
|
+
f"[tool.physverify]\nspecs = ['verify_damped_oscillator.py', {str(outside)!r}, '../verify_up.py']\n")
|
|
84
|
+
assert find_specs(project) == [(project / "verify_damped_oscillator.py").resolve()]
|
|
85
|
+
|
|
86
|
+
|
|
71
87
|
def test_mcp_lists_and_runs_the_tool(project):
|
|
72
88
|
init = handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})
|
|
73
89
|
assert init["result"]["serverInfo"]["name"] == "physverify"
|
|
@@ -532,3 +532,74 @@ def test_environment_names_and_nan_tests_are_quiet(tmp_path):
|
|
|
532
532
|
_write(tmp_path / "tests", "test_nan.py", "import math\nassert not (1.0 == math.nan)\n")
|
|
533
533
|
f = scan(tmp_path)[0]
|
|
534
534
|
assert not _by_stage(f, "names") and not _by_stage(f, "literals")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_extract_tables_returns_the_coefficients(tmp_path):
|
|
538
|
+
from physverify.butcher import check_tableau
|
|
539
|
+
from physverify.scan import extract_tables
|
|
540
|
+
|
|
541
|
+
_write(tmp_path, "rk.py", _RK4_CODE)
|
|
542
|
+
_write(tmp_path, "tab.py", '''
|
|
543
|
+
def ssprk3():
|
|
544
|
+
"""order 3"""
|
|
545
|
+
A = [[0, 0, 0], [1, 0, 0], [0.25, 0.25, 0]]
|
|
546
|
+
b = [1/6, 1/6, 2/3]
|
|
547
|
+
c = [0, 1, 0.5]
|
|
548
|
+
return A, b, c
|
|
549
|
+
''')
|
|
550
|
+
tables = {t["name"]: t for t in extract_tables(tmp_path)}
|
|
551
|
+
assert set(tables) == {"rk4", "ssprk3"}
|
|
552
|
+
assert tables["rk4"]["kind"] == "stages written as code" and tables["rk4"]["claimed"] == 4
|
|
553
|
+
t = tables["ssprk3"]
|
|
554
|
+
assert check_tableau(t["A"], t["b"], t["c"], claimed=t["claimed"]).status == "PASS"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def test_spacing_of_its_own_periodic_grid_is_not_paired_elsewhere(tmp_path):
|
|
558
|
+
_write(tmp_path, "spectral.py", """
|
|
559
|
+
import numpy as np
|
|
560
|
+
def evolve(x_min, x_max, n_points):
|
|
561
|
+
x = np.linspace(x_min, x_max, n_points, endpoint=False)
|
|
562
|
+
dx = (x_max - x_min) / n_points
|
|
563
|
+
return x, dx
|
|
564
|
+
""")
|
|
565
|
+
_write(tmp_path, "fd.py", """
|
|
566
|
+
import numpy as np
|
|
567
|
+
def states(x_min, x_max, n_points):
|
|
568
|
+
x = np.linspace(x_min, x_max, n_points)
|
|
569
|
+
return x, x[1] - x[0]
|
|
570
|
+
""")
|
|
571
|
+
assert not _by_stage(scan(tmp_path)[0], "grid")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def test_many_injected_names_become_one_note(tmp_path):
|
|
575
|
+
_write(tmp_path, "host.py", """
|
|
576
|
+
sel = NamedSelection.Create(FaceSelection.Create(GetRootPart().Faces), Selection.Empty())
|
|
577
|
+
opts = FixMissingFacesOptions()
|
|
578
|
+
DocumentSave.Execute(GetRootPart().Document.Path)
|
|
579
|
+
CloseWindow()
|
|
580
|
+
""")
|
|
581
|
+
_write(tmp_path, "forgot.py", "x = np.sin(1)\ny = plt.plot(x)\nz = sparse.eye(3)\nw = linalg.inv(z)\nv = a + b + c\n")
|
|
582
|
+
f = _by_stage(scan(tmp_path)[0], "names")
|
|
583
|
+
host = [x for x in f if x.path == "host.py"]
|
|
584
|
+
assert len(host) == 1 and host[0].level == NOTE and "run time" in host[0].message
|
|
585
|
+
assert len([x for x in f if x.path == "forgot.py" and x.level == WARN]) >= 5
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def test_constants_in_data_files_and_tables_are_summarised(tmp_path):
|
|
589
|
+
_write(tmp_path, "rates.py", "\n".join(f"r{i} = rate({2.7175 + 1e-5 * i:.6f}, {3.1405 + 1e-5 * i:.6f})"
|
|
590
|
+
for i in range(30)) + "\n")
|
|
591
|
+
_write(tmp_path, "table.py", "y = [3.1, 3.12, 3.13, 3.14, 3.15, 2.27, 2.71, 2.72]\n")
|
|
592
|
+
_write(tmp_path, "masses.py", "amu = 1.66053906660e-27\nm_n = 1.67492749804e-27\ncharge = 1.44e-19\n")
|
|
593
|
+
f = _by_stage(scan(tmp_path)[0], "constants")
|
|
594
|
+
assert [(x.path, "close to physical constants" in x.message) for x in f] == [("rates.py", True)]
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def test_test_functions_are_not_inverse_pairs(tmp_path):
|
|
598
|
+
_write(tmp_path, "t.py", """
|
|
599
|
+
def test_xyz_to_lab(): pass
|
|
600
|
+
def test_lab_to_xyz(): pass
|
|
601
|
+
def c2k(t): return t
|
|
602
|
+
def k2c(t): return t
|
|
603
|
+
""")
|
|
604
|
+
f = _by_stage(scan(tmp_path)[0], "pairs")
|
|
605
|
+
assert len(f) == 1 and "c2k" in f[0].message
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|