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.
Files changed (65) hide show
  1. {physverify-0.1.0/src/physverify.egg-info → physverify-0.2.0}/PKG-INFO +17 -3
  2. {physverify-0.1.0 → physverify-0.2.0}/README.md +16 -2
  3. {physverify-0.1.0 → physverify-0.2.0}/pyproject.toml +1 -1
  4. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/__init__.py +1 -1
  5. physverify-0.2.0/src/physverify/butcher.py +111 -0
  6. physverify-0.2.0/src/physverify/constants.py +121 -0
  7. physverify-0.2.0/src/physverify/loaders.py +94 -0
  8. physverify-0.2.0/src/physverify/method_bugs.py +151 -0
  9. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/order.py +15 -6
  10. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/ising.py +5 -0
  11. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/sampler.py +3 -2
  12. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/studies.py +64 -0
  13. {physverify-0.1.0 → physverify-0.2.0/src/physverify.egg-info}/PKG-INFO +17 -3
  14. {physverify-0.1.0 → physverify-0.2.0}/src/physverify.egg-info/SOURCES.txt +5 -0
  15. physverify-0.2.0/tests/test_corpus_lessons.py +145 -0
  16. physverify-0.2.0/tests/test_method_bugs.py +21 -0
  17. physverify-0.1.0/src/physverify/loaders.py +0 -49
  18. {physverify-0.1.0 → physverify-0.2.0}/LICENSE +0 -0
  19. {physverify-0.1.0 → physverify-0.2.0}/setup.cfg +0 -0
  20. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/__main__.py +0 -0
  21. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/__init__.py +0 -0
  22. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/double_pendulum.py +0 -0
  23. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/lorenz.py +0 -0
  24. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/projectile_v1.py +0 -0
  25. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/projectile_v2.py +0 -0
  26. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/adapters/three_body.py +0 -0
  27. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/agent.py +0 -0
  28. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/checks.py +0 -0
  29. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/cli.py +0 -0
  30. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/compare.py +0 -0
  31. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/matrix.py +0 -0
  32. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/mms.py +0 -0
  33. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/__init__.py +0 -0
  34. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/_common.py +0 -0
  35. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/heat1d.py +0 -0
  36. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/kepler.py +0 -0
  37. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/pendulum.py +0 -0
  38. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/projectile.py +0 -0
  39. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/problems/sod.py +0 -0
  40. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/pytest_plugin.py +0 -0
  41. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/report.py +0 -0
  42. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/steppers.py +0 -0
  43. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/__init__.py +0 -0
  44. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/calibrate.py +0 -0
  45. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/checks.py +0 -0
  46. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/exact.py +0 -0
  47. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/matrix.py +0 -0
  48. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/reference.py +0 -0
  49. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/stochastic/stats.py +0 -0
  50. {physverify-0.1.0 → physverify-0.2.0}/src/physverify/symbolic.py +0 -0
  51. {physverify-0.1.0 → physverify-0.2.0}/src/physverify.egg-info/dependency_links.txt +0 -0
  52. {physverify-0.1.0 → physverify-0.2.0}/src/physverify.egg-info/entry_points.txt +0 -0
  53. {physverify-0.1.0 → physverify-0.2.0}/src/physverify.egg-info/requires.txt +0 -0
  54. {physverify-0.1.0 → physverify-0.2.0}/src/physverify.egg-info/top_level.txt +0 -0
  55. {physverify-0.1.0 → physverify-0.2.0}/tests/test_agent.py +0 -0
  56. {physverify-0.1.0 → physverify-0.2.0}/tests/test_cli.py +0 -0
  57. {physverify-0.1.0 → physverify-0.2.0}/tests/test_ising.py +0 -0
  58. {physverify-0.1.0 → physverify-0.2.0}/tests/test_mms.py +0 -0
  59. {physverify-0.1.0 → physverify-0.2.0}/tests/test_new_problems.py +0 -0
  60. {physverify-0.1.0 → physverify-0.2.0}/tests/test_order.py +0 -0
  61. {physverify-0.1.0 → physverify-0.2.0}/tests/test_properties.py +0 -0
  62. {physverify-0.1.0 → physverify-0.2.0}/tests/test_real_projects.py +0 -0
  63. {physverify-0.1.0 → physverify-0.2.0}/tests/test_reference_problems.py +0 -0
  64. {physverify-0.1.0 → physverify-0.2.0}/tests/test_seeded_bugs.py +0 -0
  65. {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.1.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.1.0). Nobody besides the author has used it yet, so feedback is very welcome.
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 20 seeded bugs were caught with no false alarms.
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.1.0). Nobody besides the author has used it yet, so feedback is very welcome.
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 20 seeded bugs were caught with no false alarms.
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.1.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"
@@ -6,7 +6,7 @@ from .order import check_order, fit_order, pairwise_orders
6
6
  from .order import check_exact
7
7
  from .studies import invariant_study, ode_checks, patch_study, reference_study
8
8
 
9
- __version__ = "0.1.0"
9
+ __version__ = "0.2.0"
10
10
 
11
11
  __all__ = [
12
12
  "Check",
@@ -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
- return OrderResult(
114
- FAIL,
115
- f"error does not decrease with resolution (about {e_m[-1]:.3g} at the finest level)",
116
- fitted=0.0,
117
- **base,
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 state.spins (n_chains, L, L), state.E, state.M
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
- return -self.J * bonds - h * M, M
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.1.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.1.0). Nobody besides the author has used it yet, so feedback is very welcome.
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 20 seeded bugs were caught with no false alarms.
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