physverify 0.1.0__tar.gz → 0.2.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.1.0/src/physverify.egg-info → physverify-0.2.0}/PKG-INFO +17 -3
- {physverify-0.1.0 → physverify-0.2.0}/README.md +16 -2
- {physverify-0.1.0 → physverify-0.2.0}/pyproject.toml +1 -1
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/__init__.py +1 -1
- physverify-0.2.0/src/physverify/butcher.py +111 -0
- physverify-0.2.0/src/physverify/constants.py +121 -0
- physverify-0.2.0/src/physverify/loaders.py +94 -0
- physverify-0.2.0/src/physverify/method_bugs.py +151 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/order.py +15 -6
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/ising.py +5 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/sampler.py +3 -2
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/studies.py +64 -0
- {physverify-0.1.0 → physverify-0.2.0/src/physverify.egg-info}/PKG-INFO +17 -3
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify.egg-info/SOURCES.txt +5 -0
- physverify-0.2.0/tests/test_corpus_lessons.py +145 -0
- physverify-0.2.0/tests/test_method_bugs.py +21 -0
- physverify-0.1.0/src/physverify/loaders.py +0 -49
- {physverify-0.1.0 → physverify-0.2.0}/LICENSE +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/setup.cfg +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/__main__.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/__init__.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/double_pendulum.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/lorenz.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/projectile_v1.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/projectile_v2.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/three_body.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/agent.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/checks.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/cli.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/compare.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/matrix.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/mms.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/__init__.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/_common.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/heat1d.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/kepler.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/pendulum.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/projectile.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/sod.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/pytest_plugin.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/report.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/steppers.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/__init__.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/calibrate.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/checks.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/exact.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/matrix.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/reference.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/stats.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify/symbolic.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify.egg-info/dependency_links.txt +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify.egg-info/entry_points.txt +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify.egg-info/requires.txt +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/src/physverify.egg-info/top_level.txt +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_agent.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_cli.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_ising.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_mms.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_new_problems.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_order.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_properties.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_real_projects.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_reference_problems.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_seeded_bugs.py +0 -0
- {physverify-0.1.0 → physverify-0.2.0}/tests/test_studies.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: physverify
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -39,7 +39,7 @@ Dynamic: license-file
|
|
|
39
39
|
|
|
40
40
|
**A tool that checks whether a physics simulation is solving the right equations correctly, not just running without errors.**
|
|
41
41
|
|
|
42
|
-
Status: early version (0.
|
|
42
|
+
Status: early version (0.2.0). It has been tried on 20 public physics repositories (see below), but nobody besides the author has used it yet, so feedback is very welcome.
|
|
43
43
|
|
|
44
44
|
## The problem it addresses
|
|
45
45
|
|
|
@@ -54,6 +54,7 @@ physverify uses a few standard ideas from numerical analysis:
|
|
|
54
54
|
3. **Check conserved quantities.** Energy, mass or momentum should stay constant (or drift in a predictable way).
|
|
55
55
|
4. **Check known special cases.** For example, a projectile with no air resistance has a range you can write down by hand.
|
|
56
56
|
5. **Exact "patch" tests.** Some solutions should be reproduced to computer precision. This catches small bugs that the other checks let through.
|
|
57
|
+
6. **Algebraic checks on the method itself.** For a Runge-Kutta method, whether the coefficients really have the order they claim can be settled exactly, with no test problem (the "order conditions").
|
|
57
58
|
|
|
58
59
|
Each check reports **PASS**, **FAIL**, **EXACT** or **INCONCLUSIVE**.
|
|
59
60
|
|
|
@@ -67,10 +68,21 @@ To test it, 37 bugs were added one at a time to five small solvers (projectile,
|
|
|
67
68
|
- **0 false alarms** on the bug-free versions.
|
|
68
69
|
- The 2 missed bugs are explained in the [technical details](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md).
|
|
69
70
|
|
|
70
|
-
For the Monte Carlo checks, all
|
|
71
|
+
For the Monte Carlo checks, all 21 seeded bugs were caught with no false alarms.
|
|
71
72
|
|
|
72
73
|
The bugs and the checks were written by the same person, so these numbers show the tool works as designed. They are not a promise about bugs in other people's code.
|
|
73
74
|
|
|
75
|
+
## Trying it on other people's code
|
|
76
|
+
|
|
77
|
+
To see what it does on code it was not written for, 40 public Python physics repositories were drawn at random from GitHub searches (October 2026) and run in a locked-down container: no network, the repository read-only, nothing from it run on the author's computer.
|
|
78
|
+
|
|
79
|
+
- **20 of the 40 could be tested.** Most of the others were not numerical solvers (neural networks, apps and games, analysis tools), one needed a recent NVIDIA GPU, and a few need more set-up.
|
|
80
|
+
- **9 confirmed bugs in 6 of the 20.** For example: a measured energy that counts the magnetic-field term twice; backward time-stepping that is only first-order accurate; Runge-Kutta coefficient tables labelled with a higher order than they have; error-estimate weights that do not sum to zero; a spectral derivative that crashes for an odd number of grid points; a crash on NumPy 2.
|
|
81
|
+
- **The other 14 had no confirmed bug.** Most passed every check that applied, often to 1e-13 or better; one raised a question for its authors rather than a bug report.
|
|
82
|
+
- **Our own adapters were wrong about 15 times** before checking (a wrong constant, too-coarse steps, and so on). Every FAIL was checked by hand before being called a bug, and these mistakes are recorded, not hidden.
|
|
83
|
+
|
|
84
|
+
Each lesson became a new check or a clearer message in physverify. The testing and write-ups were done with help from an AI assistant; every number comes from running the code. Authors are told about a finding before their project is named, so the repositories are listed by ID: [results/corpus/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/corpus/SUMMARY.md).
|
|
85
|
+
|
|
74
86
|
## Try it
|
|
75
87
|
|
|
76
88
|
You need Python 3.10 or newer.
|
|
@@ -108,6 +120,8 @@ pytest my_spec.py # each check becomes a pytest tes
|
|
|
108
120
|
|
|
109
121
|
## More
|
|
110
122
|
|
|
123
|
+
- [results/corpus/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/corpus/SUMMARY.md): the 40 public repositories, what was tested and what was found.
|
|
124
|
+
- [sandbox/README.md](https://github.com/rcgiri-physics/physverify/blob/main/sandbox/README.md): the locked-down container used to run other people's code.
|
|
111
125
|
- [results/own_work/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/own_work/SUMMARY.md): results on four of the author's own public physics projects (two real bugs found and fixed).
|
|
112
126
|
- [docs/technical.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md): full details, benchmark tables, limits and related work.
|
|
113
127
|
- [docs/patch.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/patch.md): what the exact "patch" checks assume.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**A tool that checks whether a physics simulation is solving the right equations correctly, not just running without errors.**
|
|
4
4
|
|
|
5
|
-
Status: early version (0.
|
|
5
|
+
Status: early version (0.2.0). It has been tried on 20 public physics repositories (see below), but nobody besides the author has used it yet, so feedback is very welcome.
|
|
6
6
|
|
|
7
7
|
## The problem it addresses
|
|
8
8
|
|
|
@@ -17,6 +17,7 @@ physverify uses a few standard ideas from numerical analysis:
|
|
|
17
17
|
3. **Check conserved quantities.** Energy, mass or momentum should stay constant (or drift in a predictable way).
|
|
18
18
|
4. **Check known special cases.** For example, a projectile with no air resistance has a range you can write down by hand.
|
|
19
19
|
5. **Exact "patch" tests.** Some solutions should be reproduced to computer precision. This catches small bugs that the other checks let through.
|
|
20
|
+
6. **Algebraic checks on the method itself.** For a Runge-Kutta method, whether the coefficients really have the order they claim can be settled exactly, with no test problem (the "order conditions").
|
|
20
21
|
|
|
21
22
|
Each check reports **PASS**, **FAIL**, **EXACT** or **INCONCLUSIVE**.
|
|
22
23
|
|
|
@@ -30,10 +31,21 @@ To test it, 37 bugs were added one at a time to five small solvers (projectile,
|
|
|
30
31
|
- **0 false alarms** on the bug-free versions.
|
|
31
32
|
- The 2 missed bugs are explained in the [technical details](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md).
|
|
32
33
|
|
|
33
|
-
For the Monte Carlo checks, all
|
|
34
|
+
For the Monte Carlo checks, all 21 seeded bugs were caught with no false alarms.
|
|
34
35
|
|
|
35
36
|
The bugs and the checks were written by the same person, so these numbers show the tool works as designed. They are not a promise about bugs in other people's code.
|
|
36
37
|
|
|
38
|
+
## Trying it on other people's code
|
|
39
|
+
|
|
40
|
+
To see what it does on code it was not written for, 40 public Python physics repositories were drawn at random from GitHub searches (October 2026) and run in a locked-down container: no network, the repository read-only, nothing from it run on the author's computer.
|
|
41
|
+
|
|
42
|
+
- **20 of the 40 could be tested.** Most of the others were not numerical solvers (neural networks, apps and games, analysis tools), one needed a recent NVIDIA GPU, and a few need more set-up.
|
|
43
|
+
- **9 confirmed bugs in 6 of the 20.** For example: a measured energy that counts the magnetic-field term twice; backward time-stepping that is only first-order accurate; Runge-Kutta coefficient tables labelled with a higher order than they have; error-estimate weights that do not sum to zero; a spectral derivative that crashes for an odd number of grid points; a crash on NumPy 2.
|
|
44
|
+
- **The other 14 had no confirmed bug.** Most passed every check that applied, often to 1e-13 or better; one raised a question for its authors rather than a bug report.
|
|
45
|
+
- **Our own adapters were wrong about 15 times** before checking (a wrong constant, too-coarse steps, and so on). Every FAIL was checked by hand before being called a bug, and these mistakes are recorded, not hidden.
|
|
46
|
+
|
|
47
|
+
Each lesson became a new check or a clearer message in physverify. The testing and write-ups were done with help from an AI assistant; every number comes from running the code. Authors are told about a finding before their project is named, so the repositories are listed by ID: [results/corpus/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/corpus/SUMMARY.md).
|
|
48
|
+
|
|
37
49
|
## Try it
|
|
38
50
|
|
|
39
51
|
You need Python 3.10 or newer.
|
|
@@ -71,6 +83,8 @@ pytest my_spec.py # each check becomes a pytest tes
|
|
|
71
83
|
|
|
72
84
|
## More
|
|
73
85
|
|
|
86
|
+
- [results/corpus/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/corpus/SUMMARY.md): the 40 public repositories, what was tested and what was found.
|
|
87
|
+
- [sandbox/README.md](https://github.com/rcgiri-physics/physverify/blob/main/sandbox/README.md): the locked-down container used to run other people's code.
|
|
74
88
|
- [results/own_work/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/own_work/SUMMARY.md): results on four of the author's own public physics projects (two real bugs found and fixed).
|
|
75
89
|
- [docs/technical.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md): full details, benchmark tables, limits and related work.
|
|
76
90
|
- [docs/patch.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/patch.md): what the exact "patch" checks assume.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "physverify"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.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"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Order of a Runge-Kutta method from its Butcher table, by the order conditions.
|
|
2
|
+
|
|
3
|
+
A Runge-Kutta method (A, b, c) has order p exactly when b . Phi(t) = 1 / gamma(t) for every rooted
|
|
4
|
+
tree t with at most p vertices (Butcher). This is algebra on the coefficients: it needs no test
|
|
5
|
+
problem, cannot be fooled by a lucky cancellation or a superconvergent test equation, and works the
|
|
6
|
+
same for explicit and implicit methods.
|
|
7
|
+
|
|
8
|
+
Lessons from testing other people's code: one package's `order` labels were wrong for 3 of its
|
|
9
|
+
tables (one claimed 4 where the table satisfies only the order-3 conditions), and another's embedded
|
|
10
|
+
weights for Tsitouras 5(4) did not even sum to 1. Order studies on test equations found both, but
|
|
11
|
+
were slower to adjudicate than this check.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .checks import FAIL, PASS, Result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@lru_cache(maxsize=None)
|
|
24
|
+
def trees(order: int) -> tuple:
|
|
25
|
+
"""All rooted trees with `order` vertices, each a sorted tuple of child subtrees."""
|
|
26
|
+
if order == 1:
|
|
27
|
+
return ((),)
|
|
28
|
+
out = set()
|
|
29
|
+
|
|
30
|
+
def forests(n, max_child):
|
|
31
|
+
# multisets of trees with n vertices in total, children in non-increasing canonical order
|
|
32
|
+
if n == 0:
|
|
33
|
+
yield ()
|
|
34
|
+
return
|
|
35
|
+
for k in range(n, 0, -1):
|
|
36
|
+
for t in trees(k):
|
|
37
|
+
if max_child is not None and (k, t) > max_child:
|
|
38
|
+
continue
|
|
39
|
+
for rest in forests(n - k, (k, t)):
|
|
40
|
+
yield ((k, t),) + rest
|
|
41
|
+
|
|
42
|
+
for forest in forests(order - 1, None):
|
|
43
|
+
out.add(tuple(sorted((t for _, t in forest), key=repr)))
|
|
44
|
+
return tuple(sorted(out, key=repr))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _size(t) -> int:
|
|
48
|
+
return 1 + sum(_size(s) for s in t)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _gamma(t) -> int:
|
|
52
|
+
g = _size(t)
|
|
53
|
+
for s in t:
|
|
54
|
+
g *= _gamma(s)
|
|
55
|
+
return g
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _phi(t, A, e):
|
|
59
|
+
v = e.copy()
|
|
60
|
+
for s in t:
|
|
61
|
+
v = v * (A @ _phi(s, A, e))
|
|
62
|
+
return v
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def order_residuals(A, b, max_order: int = 6) -> dict:
|
|
66
|
+
"""{order: largest |b . Phi(t) - 1/gamma(t)| over the trees of that order}."""
|
|
67
|
+
A = np.asarray(A, dtype=float)
|
|
68
|
+
b = np.asarray(b, dtype=float)
|
|
69
|
+
e = np.ones(len(b))
|
|
70
|
+
return {p: max(abs(float(b @ _phi(t, A, e)) - 1.0 / _gamma(t)) for t in trees(p)) for p in range(1, max_order + 1)}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tableau_order(A, b, *, max_order: int = 6, tol: float = 1e-8) -> int:
|
|
74
|
+
"""Highest p <= max_order such that every condition up to order p holds within tol."""
|
|
75
|
+
p = 0
|
|
76
|
+
for q, r in order_residuals(A, b, max_order).items():
|
|
77
|
+
if r > tol:
|
|
78
|
+
break
|
|
79
|
+
p = q
|
|
80
|
+
return p
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_tableau(A, b, c=None, *, claimed: int, label: str = "", tol: float = 1e-8, max_order: int | None = None) -> Result:
|
|
84
|
+
"""PASS when the table has at least the claimed order; FAIL when it has less.
|
|
85
|
+
|
|
86
|
+
The default tolerance 1e-8 accepts coefficients typed with 9 or 10 digits (a real package had
|
|
87
|
+
residuals of 2e-10 for that reason), while a missing order shows residuals of 1e-4 or more.
|
|
88
|
+
The message gives the residuals, so a table whose conditions hold only to about 1e-10 is visible, and a claim that is lower than the true
|
|
89
|
+
order is noted rather than failed. If `c` is given, the row sums of A are compared with it.
|
|
90
|
+
"""
|
|
91
|
+
top = max_order or claimed + 1
|
|
92
|
+
res = order_residuals(A, b, top)
|
|
93
|
+
p = tableau_order(A, b, max_order=top, tol=tol)
|
|
94
|
+
shown = ", ".join(f"{q}: {r:.1e}" for q, r in res.items())
|
|
95
|
+
prefix = f"{label}: " if label else ""
|
|
96
|
+
notes = []
|
|
97
|
+
if c is not None:
|
|
98
|
+
rs = float(np.max(np.abs(np.asarray(A, float).sum(axis=1) - np.asarray(c, float))))
|
|
99
|
+
if rs > tol:
|
|
100
|
+
notes.append(f"row sums of A differ from c by {rs:.1e}")
|
|
101
|
+
worst_kept = max((r for q, r in res.items() if q <= max(p, 1)), default=0.0)
|
|
102
|
+
if p >= 1 and worst_kept > 1e-13:
|
|
103
|
+
notes.append(f"conditions hold only to {worst_kept:.0e} (coefficients with limited digits?)")
|
|
104
|
+
tail = ("; " + "; ".join(notes)) if notes else ""
|
|
105
|
+
if p < claimed:
|
|
106
|
+
return Result(FAIL, f"{prefix}claims order {claimed} but satisfies the order conditions only up to {p} "
|
|
107
|
+
f"(largest residual per order: {shown}){tail}")
|
|
108
|
+
if p > claimed:
|
|
109
|
+
return Result(PASS, f"{prefix}claims order {claimed}; the conditions hold up to at least {p}, so the claim is "
|
|
110
|
+
f"conservative ({shown}){tail}")
|
|
111
|
+
return Result(PASS, f"{prefix}order {claimed} confirmed by the order conditions ({shown}){tail}")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Find numeric literals in source code that are close to, but not equal to, a physical constant.
|
|
2
|
+
|
|
3
|
+
Lessons from testing other people's code: a gravitational constant in au^3 / (Msun yr^2) of
|
|
4
|
+
39.4769 (Gaussian, 365.25-day year) next to an orbital speed of 2 pi (which assumes 4 pi^2 = 39.4784),
|
|
5
|
+
g = 9 in a projectile game, pi typed as 3.14. None of these is necessarily a bug, but each is worth
|
|
6
|
+
one look, and an adapter's reference must use the code's value, not the textbook one.
|
|
7
|
+
|
|
8
|
+
`scan(path)` returns notes, never failures: the code may be right to round.
|
|
9
|
+
Several CODATA editions are accepted. When writing a reference, use the edition the code says it uses:
|
|
10
|
+
one adapter failed at 1.35e-9 by taking the 2022 proton mass where the code stated 2018.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import math
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# name: (accepted values, unit, comment)
|
|
21
|
+
KNOWN = {
|
|
22
|
+
"pi": ((math.pi,), "", "use math.pi or numpy.pi"),
|
|
23
|
+
"e (Euler)": ((math.e,), "", "use math.e or numpy.e"),
|
|
24
|
+
"g": ((9.80665, 9.81, 9.8), "m/s^2", "standard gravity 9.80665"),
|
|
25
|
+
"G (SI)": ((6.67430e-11, 6.674e-11, 6.67e-11), "m^3 kg^-1 s^-2", "CODATA 2018: 6.67430e-11"),
|
|
26
|
+
"G M_sun / 4 pi^2 (au, yr)": ((4 * math.pi**2,), "au^3 Msun^-1 yr^-2",
|
|
27
|
+
"4 pi^2 is exact for a 1 au, 1 yr circular orbit; 39.4769 is the Gaussian value for a 365.25-day year"),
|
|
28
|
+
"c": ((299792458.0, 3e8, 2.998e8), "m/s", "exact: 299792458"),
|
|
29
|
+
"h (Planck)": ((6.62607015e-34,), "J s", "exact: 6.62607015e-34"),
|
|
30
|
+
"hbar": ((1.054571817e-34,), "J s", "1.054571817e-34"),
|
|
31
|
+
"k_B": ((1.380649e-23,), "J/K", "exact: 1.380649e-23"),
|
|
32
|
+
"e (charge)": ((1.602176634e-19,), "C", "exact: 1.602176634e-19"),
|
|
33
|
+
"m_e": ((9.1093837015e-31, 9.1093837139e-31), "kg", "CODATA 2018: 9.1093837015e-31, 2022: 9.1093837139e-31"),
|
|
34
|
+
"m_p": ((1.67262192369e-27, 1.67262192595e-27), "kg", "CODATA 2018: 1.67262192369e-27, 2022: 1.67262192595e-27"),
|
|
35
|
+
"epsilon_0": ((8.8541878128e-12,), "F/m", "8.8541878128e-12"),
|
|
36
|
+
"T_c (2D Ising)": ((2 / math.log(1 + math.sqrt(2)),), "J/k_B", "2 / ln(1 + sqrt 2) = 2.269185314"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Note:
|
|
42
|
+
line: int
|
|
43
|
+
literal: str
|
|
44
|
+
constant: str
|
|
45
|
+
nearest: float
|
|
46
|
+
relative: float
|
|
47
|
+
comment: str
|
|
48
|
+
|
|
49
|
+
def __str__(self):
|
|
50
|
+
return (f"line {self.line}: {self.literal} is {self.relative:+.2g} relative to {self.constant} "
|
|
51
|
+
f"({self.nearest:.10g}); {self.comment}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _digits(x: float) -> int:
|
|
55
|
+
"""Significant digits of a literal as written (3.14 -> 3)."""
|
|
56
|
+
s = repr(x).lower().split("e")[0].replace("-", "").replace(".", "").lstrip("0")
|
|
57
|
+
return max(len(s.rstrip("0")), 1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_value(value: float, *, max_rel: float = 0.1):
|
|
61
|
+
"""(constant, nearest accepted value, relative difference) for a near miss, else None.
|
|
62
|
+
|
|
63
|
+
A value equal to an accepted value, or equal to it to the precision written with
|
|
64
|
+
at least 4 digits, is not a near miss: 3.1416 for pi and 9.81 for g are fine;
|
|
65
|
+
3.14 and 9 are noted.
|
|
66
|
+
"""
|
|
67
|
+
if value == 0 or not math.isfinite(value):
|
|
68
|
+
return None
|
|
69
|
+
best = None
|
|
70
|
+
for name, (accepted, _unit, comment) in KNOWN.items():
|
|
71
|
+
for ref in accepted:
|
|
72
|
+
rel = value / ref - 1
|
|
73
|
+
if abs(rel) > max_rel:
|
|
74
|
+
continue
|
|
75
|
+
if value == ref:
|
|
76
|
+
return None
|
|
77
|
+
written = 0.5 * 10 ** (1 - _digits(value)) # half a unit in the last written digit, relative
|
|
78
|
+
if _digits(value) >= 4 and abs(rel) <= written * 1.0000001:
|
|
79
|
+
return None # matches to the precision written, with at least 4 digits
|
|
80
|
+
if best is None or abs(rel) < abs(best[2]):
|
|
81
|
+
best = (name, ref, rel, comment)
|
|
82
|
+
return best
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# names under which a short literal (an integer, or fewer than 3 digits) is still taken to mean the constant
|
|
86
|
+
NAMES = {"g": {"g", "grav", "gravity", "g_acc", "gacc"}, "G (SI)": {"G"}, "pi": {"pi", "PI"}, "c": {"c"}}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _assigned_names(tree):
|
|
90
|
+
"""id(value node) -> names it is assigned to, for simple `name = literal` statements."""
|
|
91
|
+
out = {}
|
|
92
|
+
for node in ast.walk(tree):
|
|
93
|
+
if isinstance(node, ast.Assign):
|
|
94
|
+
out[id(node.value)] = {t.id for t in node.targets if isinstance(t, ast.Name)}
|
|
95
|
+
elif isinstance(node, ast.AnnAssign) and node.value is not None and isinstance(node.target, ast.Name):
|
|
96
|
+
out[id(node.value)] = {node.target.id}
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def scan(path, *, max_rel: float = 0.1) -> list[Note]:
|
|
101
|
+
"""Near-miss constants among the numeric literals of a Python file.
|
|
102
|
+
|
|
103
|
+
Literals with 3 or more significant digits are compared with every constant.
|
|
104
|
+
Integers and shorter literals (9, 3.1, 10) are so common that they are noted
|
|
105
|
+
only when assigned to a name that suggests the constant (g = 9).
|
|
106
|
+
"""
|
|
107
|
+
path = Path(path)
|
|
108
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
|
|
109
|
+
names = _assigned_names(tree)
|
|
110
|
+
notes = []
|
|
111
|
+
for node in ast.walk(tree):
|
|
112
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
|
|
113
|
+
v = float(node.value)
|
|
114
|
+
hit = check_value(abs(v), max_rel=max_rel)
|
|
115
|
+
if hit and (isinstance(node.value, int) or _digits(v) < 3):
|
|
116
|
+
if not names.get(id(node), set()) & NAMES.get(hit[0], set()):
|
|
117
|
+
hit = None
|
|
118
|
+
if hit:
|
|
119
|
+
name, ref, rel, comment = hit
|
|
120
|
+
notes.append(Note(node.lineno, ast.unparse(node), name, ref, rel, comment))
|
|
121
|
+
return sorted(notes, key=lambda n: n.line)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Import a module from a path without touching its folder.
|
|
2
|
+
|
|
3
|
+
Used by the adapters that test other people's code. Nothing is written next to
|
|
4
|
+
the module (no __pycache__), and optional plotting imports can be stubbed so a
|
|
5
|
+
simulation file can be imported on a machine without matplotlib.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import importlib
|
|
12
|
+
import importlib.util
|
|
13
|
+
import sys
|
|
14
|
+
import types
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _stub_module(name: str) -> types.ModuleType:
|
|
19
|
+
return types.ModuleType(name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_module_from_path(name: str, path, stubs=()):
|
|
23
|
+
path = Path(path)
|
|
24
|
+
if not path.is_file():
|
|
25
|
+
raise FileNotFoundError(path)
|
|
26
|
+
|
|
27
|
+
added = []
|
|
28
|
+
for stub in stubs:
|
|
29
|
+
try:
|
|
30
|
+
importlib.import_module(stub)
|
|
31
|
+
except ImportError:
|
|
32
|
+
sys.modules[stub] = _stub_module(stub)
|
|
33
|
+
added.append(stub)
|
|
34
|
+
|
|
35
|
+
old_flag = sys.dont_write_bytecode
|
|
36
|
+
sys.dont_write_bytecode = True
|
|
37
|
+
try:
|
|
38
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
39
|
+
module = importlib.util.module_from_spec(spec)
|
|
40
|
+
sys.modules[name] = module
|
|
41
|
+
try:
|
|
42
|
+
spec.loader.exec_module(module)
|
|
43
|
+
except BaseException:
|
|
44
|
+
sys.modules.pop(name, None) # do not leave a half-initialised module behind
|
|
45
|
+
raise
|
|
46
|
+
finally:
|
|
47
|
+
sys.dont_write_bytecode = old_flag
|
|
48
|
+
for stub in added:
|
|
49
|
+
sys.modules.pop(stub, None)
|
|
50
|
+
return module
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_definitions_from_path(name: str, path, *, keep=(), inner=(), namespace=None, stubs=()):
|
|
54
|
+
"""Load only the definitions of a script whose top level runs a simulation.
|
|
55
|
+
|
|
56
|
+
Many research scripts parse argv, open a window or run a long simulation at
|
|
57
|
+
import. This executes only the top-level imports, functions and classes, and
|
|
58
|
+
the top-level assignments to the names in `keep` (constants the functions
|
|
59
|
+
use). `inner` names functions defined inside other functions (for example
|
|
60
|
+
marimo or Jupyter-exported cells); they are lifted to module level, and
|
|
61
|
+
their imports must then come from `namespace`.
|
|
62
|
+
|
|
63
|
+
Nothing else in the file runs, so a function that relies on a global set by
|
|
64
|
+
the skipped code fails with NameError; set it on the returned module.
|
|
65
|
+
|
|
66
|
+
`stubs` names modules to replace with empty ones when they are not installed
|
|
67
|
+
(pygame, IPython, orbax, ...), for imports the tested functions do not use.
|
|
68
|
+
"""
|
|
69
|
+
path = Path(path)
|
|
70
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
|
|
71
|
+
body = []
|
|
72
|
+
for node in tree.body:
|
|
73
|
+
if isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
74
|
+
body.append(node)
|
|
75
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
76
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
77
|
+
if any(isinstance(t, ast.Name) and t.id in keep for t in targets):
|
|
78
|
+
body.append(node)
|
|
79
|
+
if inner:
|
|
80
|
+
wanted = set(inner)
|
|
81
|
+
body += [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name in wanted and n not in tree.body]
|
|
82
|
+
for stub in stubs:
|
|
83
|
+
try:
|
|
84
|
+
importlib.import_module(stub)
|
|
85
|
+
except ImportError:
|
|
86
|
+
parent, _, child = stub.rpartition(".")
|
|
87
|
+
sys.modules[stub] = _stub_module(stub)
|
|
88
|
+
if parent in sys.modules:
|
|
89
|
+
setattr(sys.modules[parent], child, sys.modules[stub])
|
|
90
|
+
module = types.ModuleType(name)
|
|
91
|
+
module.__file__ = str(path)
|
|
92
|
+
module.__dict__.update(namespace or {})
|
|
93
|
+
exec(compile(ast.Module(body=body, type_ignores=[]), str(path), "exec"), module.__dict__)
|
|
94
|
+
return module
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Seeded bugs in numerical methods themselves, modelled on bugs found in public code.
|
|
2
|
+
|
|
3
|
+
The problem benchmark (physverify.problems) seeds bugs into five solvers. Some bugs found in real
|
|
4
|
+
repositories are not bugs of a solver for one problem but of a method or a helper: a Butcher table with
|
|
5
|
+
the wrong order label, error-estimate weights that do not sum to zero, backward time-stepping with
|
|
6
|
+
stage times on the wrong side, a time loop that stops one step late, a spectral derivative that only
|
|
7
|
+
works for even grids. This module seeds each of them, with a bug-free control, and names the check
|
|
8
|
+
meant to catch it. `run_all()` returns one row per case; tests/test_method_bugs.py requires every
|
|
9
|
+
prediction to come true.
|
|
10
|
+
|
|
11
|
+
Cases and the real codes they come from are listed in results/corpus/SUMMARY.md (by repository ID).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .butcher import check_tableau
|
|
22
|
+
from .checks import FAIL, PASS, Result, run_check, Check
|
|
23
|
+
from .mms import ManufacturedODE
|
|
24
|
+
from .studies import backward_mms_study, size_sweep_check, time_grid_check
|
|
25
|
+
|
|
26
|
+
# --- Butcher tables ---------------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
HEUN_A = np.array([[0.0, 0.0], [1.0, 0.0]])
|
|
29
|
+
HEUN_B = np.array([0.5, 0.5])
|
|
30
|
+
RK4_A = np.array([[0, 0, 0, 0], [0.5, 0, 0, 0], [0, 0.5, 0, 0], [0, 0, 1, 0]], dtype=float)
|
|
31
|
+
RK4_B = np.array([1, 2, 2, 1]) / 6.0
|
|
32
|
+
# Bogacki-Shampine 3(2): main weights order 3, embedded order 2 (b_hat), error weights e = b - b_hat
|
|
33
|
+
BS_A = np.array([[0, 0, 0, 0], [0.5, 0, 0, 0], [0, 0.75, 0, 0], [2 / 9, 1 / 3, 4 / 9, 0]])
|
|
34
|
+
BS_B = np.array([2 / 9, 1 / 3, 4 / 9, 0])
|
|
35
|
+
BS_BHAT = np.array([7 / 24, 1 / 4, 1 / 3, 1 / 8])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _tableau_case(A, b, claimed):
|
|
39
|
+
return lambda: check_tableau(A, b, claimed=claimed)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- backward time-stepping -------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
MMS = ManufacturedODE(lambda t: np.array([np.exp(np.sin(t))]), lambda t, y: np.array([y[0] * np.cos(t)]))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _rk4_back(stage_times_forward: bool):
|
|
48
|
+
"""RK4 from t_end down to 0. With the bug the stages are evaluated at t + h/2, t + h (the forward side)."""
|
|
49
|
+
def integrate_back(source, y_end, t_end, n):
|
|
50
|
+
h = t_end / n
|
|
51
|
+
y, t = np.array(y_end, dtype=float), t_end
|
|
52
|
+
|
|
53
|
+
def f(s, u):
|
|
54
|
+
return MMS.rhs_ref(s, u) + source(s)
|
|
55
|
+
|
|
56
|
+
for _ in range(n):
|
|
57
|
+
sgn = 1.0 if stage_times_forward else -1.0
|
|
58
|
+
k1 = -f(t, y)
|
|
59
|
+
k2 = -f(t + sgn * h / 2, y + h / 2 * k1)
|
|
60
|
+
k3 = -f(t + sgn * h / 2, y + h / 2 * k2)
|
|
61
|
+
k4 = -f(t + sgn * h, y + h * k3)
|
|
62
|
+
y = y + h / 6 * (k1 + 2 * k2 + 2 * k3 + k4)
|
|
63
|
+
t -= h
|
|
64
|
+
return y
|
|
65
|
+
return integrate_back
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- time loops -------------------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _while_loop_times(n, t0=0.0, t1=2.0):
|
|
71
|
+
h = (t1 - t0) / n
|
|
72
|
+
t, times = t0, [t0]
|
|
73
|
+
while t <= t1:
|
|
74
|
+
t += h
|
|
75
|
+
times.append(t)
|
|
76
|
+
return times
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _counted_times(n, t0=0.0, t1=2.0):
|
|
80
|
+
h = (t1 - t0) / n
|
|
81
|
+
return [t0 + i * h for i in range(n + 1)]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- spectral derivative ----------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _spectral_derivative(halves_bug: bool):
|
|
87
|
+
def check_at(n):
|
|
88
|
+
L = 2 * np.pi
|
|
89
|
+
x = np.arange(n) * L / n
|
|
90
|
+
if halves_bug: # wavenumbers built by splitting the array in two halves: only works for even n
|
|
91
|
+
dx = L / n
|
|
92
|
+
kmax = np.pi / dx
|
|
93
|
+
k = np.arange(float(n))
|
|
94
|
+
k[: int(n / 2)] = k[: int(n / 2)] * (kmax / (n / 2))
|
|
95
|
+
k[int(n / 2):] = k[: int(n / 2)] - kmax
|
|
96
|
+
else:
|
|
97
|
+
k = 2 * np.pi * np.fft.fftfreq(n, L / n)
|
|
98
|
+
d = np.real(np.fft.ifft(1j * k * np.fft.fft(np.sin(x))))
|
|
99
|
+
return bool(np.max(np.abs(d - np.cos(x))) < 1e-10)
|
|
100
|
+
return check_at
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class MethodCase:
|
|
105
|
+
name: str
|
|
106
|
+
buggy: bool
|
|
107
|
+
description: str
|
|
108
|
+
real_case: str # repository ID in results/corpus/SUMMARY.md
|
|
109
|
+
check: str
|
|
110
|
+
run: Callable[[], Result]
|
|
111
|
+
missed_by: str = "" # a check that cannot see it, when that is the point
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
CASES = [
|
|
115
|
+
MethodCase("tableau_ok", False, "RK4 table, label 4", "-", "butcher.check_tableau", _tableau_case(RK4_A, RK4_B, 4)),
|
|
116
|
+
MethodCase("tableau_label_too_high", True, "Heun table (order 2) labelled order 3", "F09",
|
|
117
|
+
"butcher.check_tableau", _tableau_case(HEUN_A, HEUN_B, 3)),
|
|
118
|
+
MethodCase("embedded_ok", False, "Bogacki-Shampine embedded weights b_hat, label 2", "-",
|
|
119
|
+
"butcher.check_tableau", _tableau_case(BS_A, BS_BHAT, 2)),
|
|
120
|
+
MethodCase("error_weights_as_embedded", True,
|
|
121
|
+
"published error weights e = b - b_hat subtracted from b again, so the embedded weights become e "
|
|
122
|
+
"(they sum to 0)", "E09", "butcher.check_tableau", _tableau_case(BS_A, BS_B - BS_BHAT, 2)),
|
|
123
|
+
MethodCase("backward_ok", False, "RK4 stepping backwards with stages at t - h/2, t - h", "-",
|
|
124
|
+
"studies.backward_mms_study",
|
|
125
|
+
lambda: backward_mms_study(_rk4_back(False), MMS, 2.0, (10, 20, 40, 80), 4)),
|
|
126
|
+
MethodCase("backward_stage_times_forward", True, "RK4 stepping backwards with stages at t + h/2, t + h", "D08",
|
|
127
|
+
"studies.backward_mms_study",
|
|
128
|
+
lambda: backward_mms_study(_rk4_back(True), MMS, 2.0, (10, 20, 40, 80), 4),
|
|
129
|
+
missed_by="any forward-in-time check, and backward runs of autonomous equations"),
|
|
130
|
+
MethodCase("time_loop_ok", False, "loop over a counted number of steps", "-", "studies.time_grid_check",
|
|
131
|
+
lambda: time_grid_check(_counted_times, 0.0, 2.0, (4, 8, 20))),
|
|
132
|
+
MethodCase("while_loop_overshoot", True, "`while t <= t_end: t += h` takes one step too many when t lands on t_end",
|
|
133
|
+
"D08", "studies.time_grid_check", lambda: time_grid_check(_while_loop_times, 0.0, 2.0, (4, 8, 20)),
|
|
134
|
+
missed_by="order studies that read the state nearest t_end"),
|
|
135
|
+
MethodCase("spectral_ok", False, "wavenumbers from numpy.fft.fftfreq", "-", "studies.size_sweep_check",
|
|
136
|
+
lambda: size_sweep_check(_spectral_derivative(False))),
|
|
137
|
+
MethodCase("spectral_even_only", True, "wavenumbers built from two halves of the array (fails for odd n)", "G10",
|
|
138
|
+
"studies.size_sweep_check", lambda: size_sweep_check(_spectral_derivative(True)),
|
|
139
|
+
missed_by="any study on power-of-two grids"),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_all() -> list[tuple[MethodCase, Result]]:
|
|
144
|
+
return [(c, run_check(Check("method", c.name, c.run))) for c in CASES]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def to_markdown(rows) -> str:
|
|
148
|
+
lines = ["| Case | Seeded bug | From | Check | Result | Missed by |", "|---|---|---|---|---|---|"]
|
|
149
|
+
for c, r in rows:
|
|
150
|
+
lines.append(f"| {c.name} | {'yes' if c.buggy else 'no (control)'} | {c.real_case} | `{c.check}` | {r.status} | {c.missed_by} |")
|
|
151
|
+
return "\n".join(lines) + "\n"
|
|
@@ -110,18 +110,27 @@ def check_order(
|
|
|
110
110
|
return OrderResult(INCONCLUSIVE, "fewer than 2 resolutions above the noise floor", **base)
|
|
111
111
|
|
|
112
112
|
if e_m[-2] / e_m[-1] < 1.2:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
113
|
+
msg = f"error does not decrease with resolution (about {e_m[-1]:.3g} at the finest level)"
|
|
114
|
+
if len(e_m) >= 3 and e_m.max() / e_m.min() < 1.1:
|
|
115
|
+
# seen in a real MD code: one pair crossing an unshifted cutoff adds V(rc) to the energy, whatever dt is
|
|
116
|
+
msg += ("; the error is the same at every resolution, which points to something independent of the step"
|
|
117
|
+
" (a jump in the model such as a potential cutoff crossing, a wrong constant, or a reference for a"
|
|
118
|
+
" different problem) rather than to the integrator")
|
|
119
|
+
return OrderResult(FAIL, msg, fitted=0.0, **base)
|
|
119
120
|
|
|
120
121
|
h_f, e_f = h_m[-fit_points:], e_m[-fit_points:]
|
|
121
122
|
p = fit_order(h_f, e_f)
|
|
122
123
|
if p < expected - tol_low:
|
|
123
124
|
msg = f"observed order {p:.2f} is below the expected {expected:g}"
|
|
125
|
+
pairs = pairwise_orders(h_f, e_f)
|
|
126
|
+
if len(pairs) >= 2 and np.all(np.diff(pairs) > 0) and pairs[-1] >= expected - tol_low:
|
|
127
|
+
msg += (f"; the pairwise orders ({', '.join(f'{q:.2f}' for q in pairs)}) are still rising and the last one"
|
|
128
|
+
" reaches it, so the steps may be too coarse (pre-asymptotic): repeat with finer steps before"
|
|
129
|
+
" calling this a bug")
|
|
124
130
|
status = FAIL
|
|
131
|
+
if np.any(np.diff(e_f) >= 0):
|
|
132
|
+
msg += ("; the errors are not monotone over the fitted resolutions, which happens when the leading error"
|
|
133
|
+
" term changes sign or round-off is reached: try another test problem before calling this a bug")
|
|
125
134
|
elif p > expected + tol_high:
|
|
126
135
|
msg = f"observed order {p:.2f} is above the expected {expected:g}; check the expected order"
|
|
127
136
|
status = FAIL
|
|
@@ -112,6 +112,11 @@ BUGS = {
|
|
|
112
112
|
_f("bookkeeping", "scripted_rng", "moments", "acceptance"),
|
|
113
113
|
"invisible at h = 0; in a field m has the wrong sign",
|
|
114
114
|
),
|
|
115
|
+
"field_counted_twice": IsingBug(
|
|
116
|
+
"metropolis", "measured energy uses -h * (sum of the 4 neighbours) / 2 per site, i.e. -2 h M (dynamics right)",
|
|
117
|
+
_f("bookkeeping", "moments"),
|
|
118
|
+
"invisible at h = 0; in a field <e> and <e^2> are wrong while m is right (found in a real thesis code)",
|
|
119
|
+
),
|
|
115
120
|
"spins_zero_one": IsingBug(
|
|
116
121
|
"metropolis", "spins stored as {0, 1} and used in the +-1 formulas",
|
|
117
122
|
_f("bookkeeping", "scripted_rng", "exact_distribution", "moments", "acceptance", "symmetry"),
|
|
@@ -10,7 +10,7 @@ black box
|
|
|
10
10
|
the code's error bar for the mean of one chain's series (optional)
|
|
11
11
|
|
|
12
12
|
white box (optional; checks that need a method the code lacks are skipped)
|
|
13
|
-
init(L, n_chains, start, rng) -> state
|
|
13
|
+
init(L, n_chains, start, rng, h=0.0) -> state state.spins (n_chains, L, L), state.E, state.M
|
|
14
14
|
sweep(state, T, h, rng) one sweep, updating state.E and state.M incrementally
|
|
15
15
|
step(state, sites, u, T, h) one single-site update per chain at the given
|
|
16
16
|
sites (flat index) with the given uniforms
|
|
@@ -116,7 +116,8 @@ class IsingSampler:
|
|
|
116
116
|
right[:, :, -1] = 0.0
|
|
117
117
|
bonds = (s * (right + np.roll(s, -1, axis=1))).sum(axis=(1, 2))
|
|
118
118
|
M = s.sum(axis=(1, 2))
|
|
119
|
-
|
|
119
|
+
field = 2 * h if "field_counted_twice" in self.bugs else h
|
|
120
|
+
return -self.J * bonds - field * M, M
|
|
120
121
|
|
|
121
122
|
# --- set-up ---
|
|
122
123
|
|
|
@@ -144,3 +144,67 @@ def ode_checks(
|
|
|
144
144
|
p_y0, p_slope = patch
|
|
145
145
|
checks.append(Check("patch", label, lambda: patch_study(integrate, rhs_ref, p_y0, p_slope, t_end, ns)))
|
|
146
146
|
return checks
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def time_grid_check(times_of_run: Callable, t_start: float, t_stop: float, ns, *, tol: float = 1e-9) -> Result:
|
|
150
|
+
"""The run stops at t_stop after exactly n steps, for step sizes that divide the interval.
|
|
151
|
+
|
|
152
|
+
`times_of_run(n)` runs the code with step (t_stop - t_start) / n and returns
|
|
153
|
+
the times it reports. A loop such as `while t <= t_end: t += h` takes one step
|
|
154
|
+
too many whenever t lands exactly on t_end in floating point (h = 0.5, 0.25)
|
|
155
|
+
and the right number otherwise (h = 0.1), so include step sizes that are
|
|
156
|
+
exact in binary. An order study that compares at the time nearest t_stop
|
|
157
|
+
cannot see this; this check can.
|
|
158
|
+
"""
|
|
159
|
+
bad = []
|
|
160
|
+
for n in ns:
|
|
161
|
+
t = np.asarray(times_of_run(n), dtype=float)
|
|
162
|
+
h = abs(t_stop - t_start) / n
|
|
163
|
+
if len(t) != n + 1 or abs(t[-1] - t_stop) > tol * max(1.0, abs(t_stop)):
|
|
164
|
+
bad.append(f"h={h:g}: {len(t) - 1} steps ending at t={t[-1]:.6g} (expected {n} ending at {t_stop:g})")
|
|
165
|
+
if bad:
|
|
166
|
+
return Result(FAIL, "; ".join(bad))
|
|
167
|
+
return Result(PASS, f"ends at t={t_stop:g} with the expected number of steps for every h tested")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def backward_mms_study(integrate_back: Callable, mms: ManufacturedODE, t_end: float, ns, expected_order, **kw) -> OrderResult:
|
|
171
|
+
"""MMS order study integrating from t_end back to 0.
|
|
172
|
+
|
|
173
|
+
`integrate_back(source, y_end, t_end, n)` integrates y' = f(t, y) + source(t)
|
|
174
|
+
from t_end down to 0 in n steps and returns y(0). Use a manufactured solution
|
|
175
|
+
whose forcing depends on t: a code that evaluates its stages at t + h/2 while
|
|
176
|
+
stepping backwards still passes on autonomous problems but drops to first
|
|
177
|
+
order here.
|
|
178
|
+
"""
|
|
179
|
+
y_end = mms.value(t_end)
|
|
180
|
+
y_0 = mms.value(0.0)
|
|
181
|
+
kw.setdefault("scale", float(np.max(np.abs(y_0))))
|
|
182
|
+
hs, errs = [], []
|
|
183
|
+
for n in ns:
|
|
184
|
+
hs.append(t_end / n)
|
|
185
|
+
errs.append(max_abs_error(integrate_back(mms.forcing, y_end, t_end, n), y_0))
|
|
186
|
+
return check_order(hs, errs, expected_order, **kw)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def size_sweep_check(run_at_size: Callable, sizes=(15, 16, 31, 32, 63, 64), *, label: str = "") -> Result:
|
|
190
|
+
"""Call `run_at_size(n)` for odd and even sizes; FAIL if it raises for some of them.
|
|
191
|
+
|
|
192
|
+
Spectral and FFT-based codes often build their wavenumber arrays by splitting
|
|
193
|
+
the grid in two halves, which only works for even n. A real code raised a
|
|
194
|
+
ValueError for every odd grid size while being exact for even ones; an order
|
|
195
|
+
study on 16, 32, 64, 128 points cannot see that. `run_at_size` may also
|
|
196
|
+
return False to mark a wrong result at that size.
|
|
197
|
+
"""
|
|
198
|
+
bad = []
|
|
199
|
+
for n in sizes:
|
|
200
|
+
try:
|
|
201
|
+
ok = run_at_size(n)
|
|
202
|
+
except Exception as exc: # the code under test may crash; that is the finding
|
|
203
|
+
bad.append(f"n={n}: {type(exc).__name__}: {exc}")
|
|
204
|
+
continue
|
|
205
|
+
if ok is False:
|
|
206
|
+
bad.append(f"n={n}: wrong result")
|
|
207
|
+
prefix = f"{label}: " if label else ""
|
|
208
|
+
if bad:
|
|
209
|
+
return Result(FAIL, prefix + "; ".join(bad))
|
|
210
|
+
return Result(PASS, f"{prefix}runs for every size tested ({', '.join(map(str, sizes))})")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: physverify
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -39,7 +39,7 @@ Dynamic: license-file
|
|
|
39
39
|
|
|
40
40
|
**A tool that checks whether a physics simulation is solving the right equations correctly, not just running without errors.**
|
|
41
41
|
|
|
42
|
-
Status: early version (0.
|
|
42
|
+
Status: early version (0.2.0). It has been tried on 20 public physics repositories (see below), but nobody besides the author has used it yet, so feedback is very welcome.
|
|
43
43
|
|
|
44
44
|
## The problem it addresses
|
|
45
45
|
|
|
@@ -54,6 +54,7 @@ physverify uses a few standard ideas from numerical analysis:
|
|
|
54
54
|
3. **Check conserved quantities.** Energy, mass or momentum should stay constant (or drift in a predictable way).
|
|
55
55
|
4. **Check known special cases.** For example, a projectile with no air resistance has a range you can write down by hand.
|
|
56
56
|
5. **Exact "patch" tests.** Some solutions should be reproduced to computer precision. This catches small bugs that the other checks let through.
|
|
57
|
+
6. **Algebraic checks on the method itself.** For a Runge-Kutta method, whether the coefficients really have the order they claim can be settled exactly, with no test problem (the "order conditions").
|
|
57
58
|
|
|
58
59
|
Each check reports **PASS**, **FAIL**, **EXACT** or **INCONCLUSIVE**.
|
|
59
60
|
|
|
@@ -67,10 +68,21 @@ To test it, 37 bugs were added one at a time to five small solvers (projectile,
|
|
|
67
68
|
- **0 false alarms** on the bug-free versions.
|
|
68
69
|
- The 2 missed bugs are explained in the [technical details](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md).
|
|
69
70
|
|
|
70
|
-
For the Monte Carlo checks, all
|
|
71
|
+
For the Monte Carlo checks, all 21 seeded bugs were caught with no false alarms.
|
|
71
72
|
|
|
72
73
|
The bugs and the checks were written by the same person, so these numbers show the tool works as designed. They are not a promise about bugs in other people's code.
|
|
73
74
|
|
|
75
|
+
## Trying it on other people's code
|
|
76
|
+
|
|
77
|
+
To see what it does on code it was not written for, 40 public Python physics repositories were drawn at random from GitHub searches (October 2026) and run in a locked-down container: no network, the repository read-only, nothing from it run on the author's computer.
|
|
78
|
+
|
|
79
|
+
- **20 of the 40 could be tested.** Most of the others were not numerical solvers (neural networks, apps and games, analysis tools), one needed a recent NVIDIA GPU, and a few need more set-up.
|
|
80
|
+
- **9 confirmed bugs in 6 of the 20.** For example: a measured energy that counts the magnetic-field term twice; backward time-stepping that is only first-order accurate; Runge-Kutta coefficient tables labelled with a higher order than they have; error-estimate weights that do not sum to zero; a spectral derivative that crashes for an odd number of grid points; a crash on NumPy 2.
|
|
81
|
+
- **The other 14 had no confirmed bug.** Most passed every check that applied, often to 1e-13 or better; one raised a question for its authors rather than a bug report.
|
|
82
|
+
- **Our own adapters were wrong about 15 times** before checking (a wrong constant, too-coarse steps, and so on). Every FAIL was checked by hand before being called a bug, and these mistakes are recorded, not hidden.
|
|
83
|
+
|
|
84
|
+
Each lesson became a new check or a clearer message in physverify. The testing and write-ups were done with help from an AI assistant; every number comes from running the code. Authors are told about a finding before their project is named, so the repositories are listed by ID: [results/corpus/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/corpus/SUMMARY.md).
|
|
85
|
+
|
|
74
86
|
## Try it
|
|
75
87
|
|
|
76
88
|
You need Python 3.10 or newer.
|
|
@@ -108,6 +120,8 @@ pytest my_spec.py # each check becomes a pytest tes
|
|
|
108
120
|
|
|
109
121
|
## More
|
|
110
122
|
|
|
123
|
+
- [results/corpus/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/corpus/SUMMARY.md): the 40 public repositories, what was tested and what was found.
|
|
124
|
+
- [sandbox/README.md](https://github.com/rcgiri-physics/physverify/blob/main/sandbox/README.md): the locked-down container used to run other people's code.
|
|
111
125
|
- [results/own_work/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/own_work/SUMMARY.md): results on four of the author's own public physics projects (two real bugs found and fixed).
|
|
112
126
|
- [docs/technical.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md): full details, benchmark tables, limits and related work.
|
|
113
127
|
- [docs/patch.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/patch.md): what the exact "patch" checks assume.
|
|
@@ -4,11 +4,14 @@ pyproject.toml
|
|
|
4
4
|
src/physverify/__init__.py
|
|
5
5
|
src/physverify/__main__.py
|
|
6
6
|
src/physverify/agent.py
|
|
7
|
+
src/physverify/butcher.py
|
|
7
8
|
src/physverify/checks.py
|
|
8
9
|
src/physverify/cli.py
|
|
9
10
|
src/physverify/compare.py
|
|
11
|
+
src/physverify/constants.py
|
|
10
12
|
src/physverify/loaders.py
|
|
11
13
|
src/physverify/matrix.py
|
|
14
|
+
src/physverify/method_bugs.py
|
|
12
15
|
src/physverify/mms.py
|
|
13
16
|
src/physverify/order.py
|
|
14
17
|
src/physverify/pytest_plugin.py
|
|
@@ -46,7 +49,9 @@ src/physverify/stochastic/sampler.py
|
|
|
46
49
|
src/physverify/stochastic/stats.py
|
|
47
50
|
tests/test_agent.py
|
|
48
51
|
tests/test_cli.py
|
|
52
|
+
tests/test_corpus_lessons.py
|
|
49
53
|
tests/test_ising.py
|
|
54
|
+
tests/test_method_bugs.py
|
|
50
55
|
tests/test_mms.py
|
|
51
56
|
tests/test_new_problems.py
|
|
52
57
|
tests/test_order.py
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Checks added after testing other people's repositories (sandbox/README.md).
|
|
2
|
+
|
|
3
|
+
Each test reproduces, in a few lines, the pattern a real code had, and shows the new check sees it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from physverify.checks import FAIL, PASS
|
|
9
|
+
from physverify.constants import check_value, scan
|
|
10
|
+
from physverify.loaders import load_definitions_from_path
|
|
11
|
+
from physverify.mms import ManufacturedODE
|
|
12
|
+
from physverify.order import check_order
|
|
13
|
+
from physverify.steppers import rk4
|
|
14
|
+
from physverify.studies import backward_mms_study, time_grid_check
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def while_loop_times(t0, t1, n):
|
|
18
|
+
"""`while t <= t_end: t += h`, as in a real RK4 library."""
|
|
19
|
+
h = (t1 - t0) / n
|
|
20
|
+
t, times = t0, [t0]
|
|
21
|
+
while t <= t1:
|
|
22
|
+
t += h
|
|
23
|
+
times.append(t)
|
|
24
|
+
return times
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def counted_times(t0, t1, n):
|
|
28
|
+
h = (t1 - t0) / n
|
|
29
|
+
return [t0 + i * h for i in range(n + 1)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_time_grid_check_catches_the_extra_step_only_for_exact_binary_steps():
|
|
33
|
+
bad = time_grid_check(lambda n: while_loop_times(0.0, 2.0, n), 0.0, 2.0, (4, 8, 20))
|
|
34
|
+
assert bad.status == FAIL
|
|
35
|
+
assert "h=0.5" in bad.message and "h=0.25" in bad.message and "h=0.1:" not in bad.message
|
|
36
|
+
assert time_grid_check(lambda n: counted_times(0.0, 2.0, n), 0.0, 2.0, (4, 8, 20)).status == PASS
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
MMS = ManufacturedODE(lambda t: np.array([np.exp(np.sin(t))]), lambda t, y: np.array([y[0] * np.cos(t)]))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def backward(stage_time_sign):
|
|
43
|
+
"""RK4 from t_end down to 0; stage_time_sign = +1 reproduces the bug (stages at t + h/2 while stepping back)."""
|
|
44
|
+
def integrate_back(source, y_end, t_end, n):
|
|
45
|
+
h = t_end / n
|
|
46
|
+
y, t = np.array(y_end, dtype=float), t_end
|
|
47
|
+
g = lambda s, u: -(MMS.rhs_ref(s, u) + source(s)) # dy/d(-t)
|
|
48
|
+
for _ in range(n):
|
|
49
|
+
if stage_time_sign > 0:
|
|
50
|
+
y = rk4(g, t, y, h)
|
|
51
|
+
else:
|
|
52
|
+
y = rk4(lambda s, u: g(-s, u), -t, y, h)
|
|
53
|
+
t -= h
|
|
54
|
+
return y
|
|
55
|
+
return integrate_back
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_backward_study_separates_wrong_stage_times():
|
|
59
|
+
ns = (10, 20, 40, 80)
|
|
60
|
+
assert backward_mms_study(backward(-1), MMS, 2.0, ns, 4).status == PASS
|
|
61
|
+
bad = backward_mms_study(backward(+1), MMS, 2.0, ns, 4)
|
|
62
|
+
assert bad.status == FAIL and bad.fitted < 1.5
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_order_check_says_when_the_steps_are_too_coarse():
|
|
66
|
+
hs = np.array([0.04, 0.02, 0.01, 0.005])
|
|
67
|
+
errs = hs**4 / (1 + 40 * hs) # pre-asymptotic: pairwise orders 3.47, 3.64, 3.78, climbing toward 4
|
|
68
|
+
r = check_order(hs, errs, 4.0)
|
|
69
|
+
assert r.status == FAIL and "pre-asymptotic" in r.message
|
|
70
|
+
assert "pre-asymptotic" not in check_order(hs, hs**2, 4.0).message
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_constant_near_misses(tmp_path):
|
|
74
|
+
assert check_value(3.14)[0] == "pi" and check_value(3.1416) is None
|
|
75
|
+
assert check_value(39.47692640889762)[0].startswith("G M_sun") and check_value(4 * np.pi**2) is None
|
|
76
|
+
assert check_value(9.81) is None and check_value(6.674e-11) is None
|
|
77
|
+
src = tmp_path / "game.py"
|
|
78
|
+
src.write_text("g = 9\nn = 10\nU = 110\nG = 39.47692640889762\nx = 3.14 * 2\n")
|
|
79
|
+
names = [n.constant for n in scan(src)]
|
|
80
|
+
assert names == ["g", "G M_sun / 4 pi^2 (au, yr)", "pi"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_load_definitions_skips_the_script_body(tmp_path):
|
|
84
|
+
src = tmp_path / "sim.py"
|
|
85
|
+
src.write_text(
|
|
86
|
+
"import math\nimport argparse\nargs = argparse.ArgumentParser().parse_args(['--bogus'])\n"
|
|
87
|
+
"J = 2.0\nraise SystemExit('the simulation ran')\n"
|
|
88
|
+
"def energy(s):\n return -J * s * math.cos(0)\n"
|
|
89
|
+
"def cell():\n def inner(x):\n return x + 1\n return inner\n"
|
|
90
|
+
)
|
|
91
|
+
mod = load_definitions_from_path("sim_defs", src, keep=("J",), inner=("inner",))
|
|
92
|
+
assert mod.energy(3.0) == -6.0 and mod.inner(1) == 2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_butcher_order_conditions():
|
|
96
|
+
from physverify.butcher import check_tableau, tableau_order, trees
|
|
97
|
+
|
|
98
|
+
assert [len(trees(n)) for n in range(1, 8)] == [1, 1, 2, 4, 9, 20, 48]
|
|
99
|
+
A = np.array([[0, 0, 0, 0], [0.5, 0, 0, 0], [0, 0.5, 0, 0], [0, 0, 1, 0]])
|
|
100
|
+
b = np.array([1 / 6, 1 / 3, 1 / 3, 1 / 6])
|
|
101
|
+
assert tableau_order(A, b) == 4
|
|
102
|
+
assert check_tableau(A, b, [0, 0.5, 0.5, 1], claimed=4).status == PASS
|
|
103
|
+
# a real case: an order-3 table labelled as order 4
|
|
104
|
+
bad = check_tableau(A, b, claimed=5)
|
|
105
|
+
assert bad.status == FAIL and "only up to 4" in bad.message
|
|
106
|
+
s = np.sqrt(15)
|
|
107
|
+
gauss3 = np.array([[5 / 36, 2 / 9 - s / 15, 5 / 36 - s / 30], [5 / 36 + s / 24, 2 / 9, 5 / 36 - s / 24],
|
|
108
|
+
[5 / 36 + s / 30, 2 / 9 + s / 15, 5 / 36]])
|
|
109
|
+
assert tableau_order(gauss3, [5 / 18, 4 / 9, 5 / 18], max_order=7) == 6 # implicit tables work too
|
|
110
|
+
# coefficients typed with few digits are noted
|
|
111
|
+
rough = check_tableau(np.round(gauss3, 8), [5 / 18, 4 / 9, 5 / 18], claimed=6, tol=1e-6)
|
|
112
|
+
assert rough.status == PASS and "limited digits" in rough.message
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_order_check_flags_non_monotone_errors():
|
|
116
|
+
hs = np.array([0.1, 0.05, 0.025, 0.0125])
|
|
117
|
+
errs = np.array([1e-6, 1e-8, 2e-8, 1e-8]) # leading term changes sign between the second and third
|
|
118
|
+
r = check_order(hs, errs, 4.0, floor=1e-14)
|
|
119
|
+
assert r.status == FAIL and "not monotone" in r.message
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_order_check_explains_a_step_independent_error():
|
|
123
|
+
hs = np.array([0.004, 0.002, 0.001, 0.0005])
|
|
124
|
+
r = check_order(hs, np.array([0.0164, 0.0164, 0.0163, 0.0163]), 2.0) # the MD cutoff-crossing case
|
|
125
|
+
assert r.status == FAIL and "independent of the step" in r.message
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_size_sweep_finds_a_crash_at_odd_sizes():
|
|
129
|
+
from physverify.studies import size_sweep_check
|
|
130
|
+
|
|
131
|
+
def halves(n): # the pattern of a real spectral code
|
|
132
|
+
k = np.arange(float(n))
|
|
133
|
+
k[int(n / 2):] = k[: int(n / 2)] - 1.0
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
r = size_sweep_check(halves)
|
|
137
|
+
assert r.status == FAIL and "n=15" in r.message and "n=16" not in r.message
|
|
138
|
+
assert size_sweep_check(lambda n: np.fft.fftfreq(n).size == n).status == PASS
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_load_definitions_stubs_missing_imports(tmp_path):
|
|
142
|
+
src = tmp_path / "sim.py"
|
|
143
|
+
src.write_text("import not_installed_plotting_pkg\nimport math\ndef f(x):\n return math.sqrt(x)\n")
|
|
144
|
+
mod = load_definitions_from_path("sim_stub", src, stubs=("not_installed_plotting_pkg",))
|
|
145
|
+
assert mod.f(4.0) == 2.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Every seeded method bug is caught by the check named for it, and every control passes."""
|
|
2
|
+
|
|
3
|
+
from physverify.checks import FAIL
|
|
4
|
+
from physverify.method_bugs import run_all
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_every_method_bug_is_caught_and_every_control_passes():
|
|
8
|
+
for case, result in run_all():
|
|
9
|
+
if case.buggy:
|
|
10
|
+
assert result.status == FAIL, (case.name, result.message)
|
|
11
|
+
else:
|
|
12
|
+
assert result.status in ("PASS", "EXACT"), (case.name, result.message)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_committed_method_bug_table_matches_a_fresh_run():
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from physverify.method_bugs import to_markdown
|
|
19
|
+
|
|
20
|
+
committed = (Path(__file__).resolve().parents[1] / "results" / "method_bugs.md").read_text(encoding="utf-8")
|
|
21
|
+
assert to_markdown(run_all()) in committed
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
"""Import a module from a path without touching its folder.
|
|
2
|
-
|
|
3
|
-
Used by the adapters that test other people's code. Nothing is written next to
|
|
4
|
-
the module (no __pycache__), and optional plotting imports can be stubbed so a
|
|
5
|
-
simulation file can be imported on a machine without matplotlib.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import importlib
|
|
11
|
-
import importlib.util
|
|
12
|
-
import sys
|
|
13
|
-
import types
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _stub_module(name: str) -> types.ModuleType:
|
|
18
|
-
return types.ModuleType(name)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def load_module_from_path(name: str, path, stubs=()):
|
|
22
|
-
path = Path(path)
|
|
23
|
-
if not path.is_file():
|
|
24
|
-
raise FileNotFoundError(path)
|
|
25
|
-
|
|
26
|
-
added = []
|
|
27
|
-
for stub in stubs:
|
|
28
|
-
try:
|
|
29
|
-
importlib.import_module(stub)
|
|
30
|
-
except ImportError:
|
|
31
|
-
sys.modules[stub] = _stub_module(stub)
|
|
32
|
-
added.append(stub)
|
|
33
|
-
|
|
34
|
-
old_flag = sys.dont_write_bytecode
|
|
35
|
-
sys.dont_write_bytecode = True
|
|
36
|
-
try:
|
|
37
|
-
spec = importlib.util.spec_from_file_location(name, path)
|
|
38
|
-
module = importlib.util.module_from_spec(spec)
|
|
39
|
-
sys.modules[name] = module
|
|
40
|
-
try:
|
|
41
|
-
spec.loader.exec_module(module)
|
|
42
|
-
except BaseException:
|
|
43
|
-
sys.modules.pop(name, None) # do not leave a half-initialised module behind
|
|
44
|
-
raise
|
|
45
|
-
finally:
|
|
46
|
-
sys.dont_write_bytecode = old_flag
|
|
47
|
-
for stub in added:
|
|
48
|
-
sys.modules.pop(stub, None)
|
|
49
|
-
return module
|
|
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
|