physverify 0.2.0__tar.gz → 0.3.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 (67) hide show
  1. {physverify-0.2.0/src/physverify.egg-info → physverify-0.3.0}/PKG-INFO +11 -9
  2. {physverify-0.2.0 → physverify-0.3.0}/README.md +10 -8
  3. {physverify-0.2.0 → physverify-0.3.0}/pyproject.toml +1 -1
  4. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/__init__.py +1 -1
  5. physverify-0.3.0/src/physverify/loaders.py +178 -0
  6. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/method_bugs.py +117 -2
  7. physverify-0.3.0/src/physverify/references.py +128 -0
  8. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/studies.py +26 -0
  9. {physverify-0.2.0 → physverify-0.3.0/src/physverify.egg-info}/PKG-INFO +11 -9
  10. {physverify-0.2.0 → physverify-0.3.0}/src/physverify.egg-info/SOURCES.txt +2 -0
  11. {physverify-0.2.0 → physverify-0.3.0}/tests/test_corpus_lessons.py +45 -0
  12. physverify-0.3.0/tests/test_references.py +103 -0
  13. physverify-0.2.0/src/physverify/loaders.py +0 -94
  14. {physverify-0.2.0 → physverify-0.3.0}/LICENSE +0 -0
  15. {physverify-0.2.0 → physverify-0.3.0}/setup.cfg +0 -0
  16. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/__main__.py +0 -0
  17. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/adapters/__init__.py +0 -0
  18. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/adapters/double_pendulum.py +0 -0
  19. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/adapters/lorenz.py +0 -0
  20. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/adapters/projectile_v1.py +0 -0
  21. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/adapters/projectile_v2.py +0 -0
  22. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/adapters/three_body.py +0 -0
  23. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/agent.py +0 -0
  24. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/butcher.py +0 -0
  25. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/checks.py +0 -0
  26. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/cli.py +0 -0
  27. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/compare.py +0 -0
  28. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/constants.py +0 -0
  29. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/matrix.py +0 -0
  30. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/mms.py +0 -0
  31. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/order.py +0 -0
  32. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/problems/__init__.py +0 -0
  33. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/problems/_common.py +0 -0
  34. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/problems/heat1d.py +0 -0
  35. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/problems/kepler.py +0 -0
  36. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/problems/pendulum.py +0 -0
  37. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/problems/projectile.py +0 -0
  38. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/problems/sod.py +0 -0
  39. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/pytest_plugin.py +0 -0
  40. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/report.py +0 -0
  41. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/steppers.py +0 -0
  42. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/stochastic/__init__.py +0 -0
  43. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/stochastic/calibrate.py +0 -0
  44. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/stochastic/checks.py +0 -0
  45. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/stochastic/exact.py +0 -0
  46. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/stochastic/ising.py +0 -0
  47. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/stochastic/matrix.py +0 -0
  48. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/stochastic/reference.py +0 -0
  49. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/stochastic/sampler.py +0 -0
  50. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/stochastic/stats.py +0 -0
  51. {physverify-0.2.0 → physverify-0.3.0}/src/physverify/symbolic.py +0 -0
  52. {physverify-0.2.0 → physverify-0.3.0}/src/physverify.egg-info/dependency_links.txt +0 -0
  53. {physverify-0.2.0 → physverify-0.3.0}/src/physverify.egg-info/entry_points.txt +0 -0
  54. {physverify-0.2.0 → physverify-0.3.0}/src/physverify.egg-info/requires.txt +0 -0
  55. {physverify-0.2.0 → physverify-0.3.0}/src/physverify.egg-info/top_level.txt +0 -0
  56. {physverify-0.2.0 → physverify-0.3.0}/tests/test_agent.py +0 -0
  57. {physverify-0.2.0 → physverify-0.3.0}/tests/test_cli.py +0 -0
  58. {physverify-0.2.0 → physverify-0.3.0}/tests/test_ising.py +0 -0
  59. {physverify-0.2.0 → physverify-0.3.0}/tests/test_method_bugs.py +0 -0
  60. {physverify-0.2.0 → physverify-0.3.0}/tests/test_mms.py +0 -0
  61. {physverify-0.2.0 → physverify-0.3.0}/tests/test_new_problems.py +0 -0
  62. {physverify-0.2.0 → physverify-0.3.0}/tests/test_order.py +0 -0
  63. {physverify-0.2.0 → physverify-0.3.0}/tests/test_properties.py +0 -0
  64. {physverify-0.2.0 → physverify-0.3.0}/tests/test_real_projects.py +0 -0
  65. {physverify-0.2.0 → physverify-0.3.0}/tests/test_reference_problems.py +0 -0
  66. {physverify-0.2.0 → physverify-0.3.0}/tests/test_seeded_bugs.py +0 -0
  67. {physverify-0.2.0 → physverify-0.3.0}/tests/test_studies.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: physverify
3
- Version: 0.2.0
3
+ Version: 0.3.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.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.
42
+ Status: early version (0.3.0). It has been tried on 68 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
 
@@ -51,7 +51,7 @@ physverify uses a few standard ideas from numerical analysis:
51
51
 
52
52
  1. **Make up an answer, then check the code finds it.** Pick a smooth function, work out what extra push (a "source term") would make it an exact solution, give that push to your code, and see if your code reproduces the function. This is called the *Method of Manufactured Solutions*.
53
53
  2. **Check the error shrinks at the right speed.** If your method is supposed to be 4th order, halving the time step should make the error about 16 times smaller. If it only gets 2 times smaller, something is wrong, even if the error looked small.
54
- 3. **Check conserved quantities.** Energy, mass or momentum should stay constant (or drift in a predictable way).
54
+ 3. **Check conserved quantities and budgets.** Energy, mass or momentum should stay constant (or drift in a predictable way), and heat added through a wall or a source should match what went in, exactly, on every grid.
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
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").
@@ -74,12 +74,14 @@ The bugs and the checks were written by the same person, so these numbers show t
74
74
 
75
75
  ## Trying it on other people's code
76
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.
77
+ To see what it does on code it was not written for, public Python physics repositories from GitHub were run in a locked-down container (October 2026): no network, the repository read-only, nothing from it run on the author's computer. 40 were drawn at random from topic searches (batches 1 to 4); another 60 were the newest small repositories whose README says the work is complete (batch 5), and 20 more were picked because they are quick to test (batch 6); batches 5 and 6 are not random.
78
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.
79
+ - **68 of the 120 could be tested.** Most of the others were not numerical solvers (neural networks, apps and games, analysis tools, finance), one needed a recent NVIDIA GPU, and a few need more set-up.
80
+ - **16 confirmed bugs in 12 of the 68.** 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; a spectral derivative that crashes for an odd number of grid points; boundary values applied after the linear solve instead of inside it; an insulated wall that leaks heat; a grid spacing off by one interval; a Kepler solver that fails for negative angles.
81
+ - **The rest had no confirmed bug.** Most passed every check that applied, often to 1e-13 or better.
82
+ - **Our own adapters were wrong about 28 times** before checking (a wrong constant, too-coarse steps, a wrong sign convention, and so on). Every FAIL was checked by hand before being called a bug, and these mistakes are recorded, not hidden.
83
+
84
+ These counts describe the repositories that were drawn; they are not a bug rate for physics code in general.
83
85
 
84
86
  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
87
 
@@ -120,7 +122,7 @@ pytest my_spec.py # each check becomes a pytest tes
120
122
 
121
123
  ## More
122
124
 
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.
125
+ - [results/corpus/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/corpus/SUMMARY.md): the 120 public repositories, what was tested and what was found.
124
126
  - [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.
125
127
  - [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).
126
128
  - [docs/technical.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md): full details, benchmark tables, limits and related work.
@@ -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.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.
5
+ Status: early version (0.3.0). It has been tried on 68 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
 
@@ -14,7 +14,7 @@ physverify uses a few standard ideas from numerical analysis:
14
14
 
15
15
  1. **Make up an answer, then check the code finds it.** Pick a smooth function, work out what extra push (a "source term") would make it an exact solution, give that push to your code, and see if your code reproduces the function. This is called the *Method of Manufactured Solutions*.
16
16
  2. **Check the error shrinks at the right speed.** If your method is supposed to be 4th order, halving the time step should make the error about 16 times smaller. If it only gets 2 times smaller, something is wrong, even if the error looked small.
17
- 3. **Check conserved quantities.** Energy, mass or momentum should stay constant (or drift in a predictable way).
17
+ 3. **Check conserved quantities and budgets.** Energy, mass or momentum should stay constant (or drift in a predictable way), and heat added through a wall or a source should match what went in, exactly, on every grid.
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
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").
@@ -37,12 +37,14 @@ The bugs and the checks were written by the same person, so these numbers show t
37
37
 
38
38
  ## Trying it on other people's code
39
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.
40
+ To see what it does on code it was not written for, public Python physics repositories from GitHub were run in a locked-down container (October 2026): no network, the repository read-only, nothing from it run on the author's computer. 40 were drawn at random from topic searches (batches 1 to 4); another 60 were the newest small repositories whose README says the work is complete (batch 5), and 20 more were picked because they are quick to test (batch 6); batches 5 and 6 are not random.
41
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.
42
+ - **68 of the 120 could be tested.** Most of the others were not numerical solvers (neural networks, apps and games, analysis tools, finance), one needed a recent NVIDIA GPU, and a few need more set-up.
43
+ - **16 confirmed bugs in 12 of the 68.** 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; a spectral derivative that crashes for an odd number of grid points; boundary values applied after the linear solve instead of inside it; an insulated wall that leaks heat; a grid spacing off by one interval; a Kepler solver that fails for negative angles.
44
+ - **The rest had no confirmed bug.** Most passed every check that applied, often to 1e-13 or better.
45
+ - **Our own adapters were wrong about 28 times** before checking (a wrong constant, too-coarse steps, a wrong sign convention, and so on). Every FAIL was checked by hand before being called a bug, and these mistakes are recorded, not hidden.
46
+
47
+ These counts describe the repositories that were drawn; they are not a bug rate for physics code in general.
46
48
 
47
49
  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
50
 
@@ -83,7 +85,7 @@ pytest my_spec.py # each check becomes a pytest tes
83
85
 
84
86
  ## More
85
87
 
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.
88
+ - [results/corpus/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/corpus/SUMMARY.md): the 120 public repositories, what was tested and what was found.
87
89
  - [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.
88
90
  - [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).
89
91
  - [docs/technical.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md): full details, benchmark tables, limits and related work.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "physverify"
7
- version = "0.2.0"
7
+ version = "0.3.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.2.0"
9
+ __version__ = "0.3.0"
10
10
 
11
11
  __all__ = [
12
12
  "Check",
@@ -0,0 +1,178 @@
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
+ class Placeholder:
19
+ """Stand-in for a name from a module that is not installed.
20
+
21
+ Calling it returns another placeholder, except that calling it with a single
22
+ function returns that function, so stubbed decorators (``@app.route(...)``,
23
+ ``@jit``) leave the decorated code usable. Attribute access also returns a
24
+ placeholder. A tested code path that really uses the stub gets a placeholder
25
+ back instead of a result, which the checks then report.
26
+ """
27
+
28
+ def __init__(self, name: str = "stub"):
29
+ self._name = name
30
+
31
+ def __call__(self, *args, **kwargs):
32
+ if len(args) == 1 and not kwargs and callable(args[0]) and not isinstance(args[0], Placeholder):
33
+ return args[0]
34
+ return Placeholder(f"{self._name}()")
35
+
36
+ def __getattr__(self, attr):
37
+ if attr.startswith("__"):
38
+ raise AttributeError(attr)
39
+ return Placeholder(f"{self._name}.{attr}")
40
+
41
+ def __repr__(self):
42
+ return f"<stub {self._name}>"
43
+
44
+
45
+ def _stub_module(name: str, attrs=()) -> types.ModuleType:
46
+ mod = types.ModuleType(name)
47
+ mod.__dict__["__stub__"] = True
48
+ for a in attrs:
49
+ setattr(mod, a, Placeholder(f"{name}.{a}"))
50
+ return mod
51
+
52
+
53
+ def stub_modules(spec, *, force: bool = False) -> list[str]:
54
+ """Install stub modules for imports a tested code path does not use.
55
+
56
+ `spec` is an iterable of module names, or a mapping from a module name to the
57
+ names it must provide (``{"sklearn.metrics": ["accuracy_score"]}``), so that
58
+ ``from sklearn.metrics import accuracy_score`` succeeds. Parent packages are
59
+ created as needed. A module that imports for real is left alone unless
60
+ `force` is true. Returns the names installed.
61
+ """
62
+ items = spec.items() if hasattr(spec, "items") else ((s, ()) for s in spec)
63
+ installed = []
64
+ for name, attrs in items:
65
+ if not force:
66
+ try:
67
+ importlib.import_module(name)
68
+ continue
69
+ except ImportError:
70
+ pass
71
+ parts = name.split(".")
72
+ for i in range(1, len(parts)):
73
+ parent = ".".join(parts[:i])
74
+ if parent not in sys.modules:
75
+ sys.modules[parent] = _stub_module(parent)
76
+ installed.append(parent)
77
+ sys.modules[name] = _stub_module(name, attrs)
78
+ installed.append(name)
79
+ parent, _, child = name.rpartition(".")
80
+ if parent:
81
+ setattr(sys.modules[parent], child, sys.modules[name])
82
+ return installed
83
+
84
+
85
+ def run_script(path, *, argv=(), stubs=None) -> tuple[dict, str]:
86
+ """Run a whole script as __main__ and return (its globals, what it printed).
87
+
88
+ For scripts whose results are only module-level variables (coursework,
89
+ notebooks exported to .py, demos). Matplotlib is switched to a non-interactive
90
+ backend and plt.show() does nothing, so the run never waits for a window.
91
+ """
92
+ import contextlib
93
+ import io
94
+ import runpy
95
+
96
+ if stubs:
97
+ stub_modules(stubs)
98
+ try:
99
+ import matplotlib
100
+
101
+ matplotlib.use("Agg")
102
+ import matplotlib.pyplot as plt
103
+
104
+ plt.show = lambda *a, **k: None
105
+ except ImportError:
106
+ pass
107
+ old_argv = sys.argv
108
+ sys.argv = [str(path), *argv]
109
+ out = io.StringIO()
110
+ try:
111
+ with contextlib.redirect_stdout(out):
112
+ globs = runpy.run_path(str(path), run_name="__main__")
113
+ finally:
114
+ sys.argv = old_argv
115
+ return globs, out.getvalue()
116
+
117
+
118
+ def load_module_from_path(name: str, path, stubs=()):
119
+ path = Path(path)
120
+ if not path.is_file():
121
+ raise FileNotFoundError(path)
122
+
123
+ added = stub_modules(stubs)
124
+
125
+ old_flag = sys.dont_write_bytecode
126
+ sys.dont_write_bytecode = True
127
+ try:
128
+ spec = importlib.util.spec_from_file_location(name, path)
129
+ module = importlib.util.module_from_spec(spec)
130
+ sys.modules[name] = module
131
+ try:
132
+ spec.loader.exec_module(module)
133
+ except BaseException:
134
+ sys.modules.pop(name, None) # do not leave a half-initialised module behind
135
+ raise
136
+ finally:
137
+ sys.dont_write_bytecode = old_flag
138
+ for stub in added:
139
+ sys.modules.pop(stub, None)
140
+ return module
141
+
142
+
143
+ def load_definitions_from_path(name: str, path, *, keep=(), inner=(), namespace=None, stubs=()):
144
+ """Load only the definitions of a script whose top level runs a simulation.
145
+
146
+ Many research scripts parse argv, open a window or run a long simulation at
147
+ import. This executes only the top-level imports, functions and classes, and
148
+ the top-level assignments to the names in `keep` (constants the functions
149
+ use). `inner` names functions defined inside other functions (for example
150
+ marimo or Jupyter-exported cells); they are lifted to module level, and
151
+ their imports must then come from `namespace`.
152
+
153
+ Nothing else in the file runs, so a function that relies on a global set by
154
+ the skipped code fails with NameError; set it on the returned module.
155
+
156
+ `stubs` names modules (or maps module names to the names they must provide, see
157
+ `stub_modules`) to replace with stubs when they are not installed
158
+ (pygame, IPython, orbax, ...), for imports the tested functions do not use.
159
+ """
160
+ path = Path(path)
161
+ tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
162
+ body = []
163
+ for node in tree.body:
164
+ if isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
165
+ body.append(node)
166
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
167
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
168
+ if any(isinstance(t, ast.Name) and t.id in keep for t in targets):
169
+ body.append(node)
170
+ if inner:
171
+ wanted = set(inner)
172
+ body += [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name in wanted and n not in tree.body]
173
+ stub_modules(stubs)
174
+ module = types.ModuleType(name)
175
+ module.__file__ = str(path)
176
+ module.__dict__.update(namespace or {})
177
+ exec(compile(ast.Module(body=body, type_ignores=[]), str(path), "exec"), module.__dict__)
178
+ return module
@@ -4,7 +4,9 @@ The problem benchmark (physverify.problems) seeds bugs into five solvers. Some b
4
4
  repositories are not bugs of a solver for one problem but of a method or a helper: a Butcher table with
5
5
  the wrong order label, error-estimate weights that do not sum to zero, backward time-stepping with
6
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
7
+ works for even grids, Dirichlet values written after the solve instead of inside the linear system, a
8
+ zero-flux wall made by copying the neighbour value, a source term without its factor dt, a grid spacing
9
+ off by one interval. This module seeds each of them, with a bug-free control, and names the check
8
10
  meant to catch it. `run_all()` returns one row per case; tests/test_method_bugs.py requires every
9
11
  prediction to come true.
10
12
 
@@ -21,7 +23,8 @@ import numpy as np
21
23
  from .butcher import check_tableau
22
24
  from .checks import FAIL, PASS, Result, run_check, Check
23
25
  from .mms import ManufacturedODE
24
- from .studies import backward_mms_study, size_sweep_check, time_grid_check
26
+ from .order import check_order
27
+ from .studies import backward_mms_study, balance_check, size_sweep_check, time_grid_check
25
28
 
26
29
  # --- Butcher tables ---------------------------------------------------------------------------------
27
30
 
@@ -100,6 +103,97 @@ def _spectral_derivative(halves_bug: bool):
100
103
  return check_at
101
104
 
102
105
 
106
+ # --- heat equation boundaries and sources (batch 5) -------------------------------------------------
107
+
108
+ def _cn_dirichlet_error(n: int, boundary_in_system: bool) -> float:
109
+ """Crank-Nicolson for u_t = u_xx on [0, 1], u = 0 at both ends, mode sin(pi x), t = 0.05, small dt.
110
+
111
+ With the bug the matrix covers all n points, boundary rows included, and the Dirichlet values are
112
+ written into the solution only after each solve, so the boundary nodes evolve freely meanwhile.
113
+ """
114
+ x = np.linspace(0.0, 1.0, n)
115
+ dx, t_end, steps = x[1] - x[0], 0.05, 2000
116
+ dt = t_end / steps
117
+ lam = dt / dx**2
118
+ u = np.sin(np.pi * x)
119
+ if boundary_in_system:
120
+ m = n - 2
121
+ D = (np.diag(-2.0 * np.ones(m)) + np.diag(np.ones(m - 1), 1) + np.diag(np.ones(m - 1), -1)) * lam
122
+ A, B = np.eye(m) - D / 2, np.eye(m) + D / 2
123
+ v = u[1:-1]
124
+ for _ in range(steps):
125
+ v = np.linalg.solve(A, B @ v)
126
+ u = np.concatenate([[0.0], v, [0.0]])
127
+ else:
128
+ D = (np.diag(-2.0 * np.ones(n)) + np.diag(np.ones(n - 1), 1) + np.diag(np.ones(n - 1), -1)) * lam
129
+ A, B = np.eye(n) - D / 2, np.eye(n) + D / 2
130
+ for _ in range(steps):
131
+ u = np.linalg.solve(A, B @ u)
132
+ u[0] = u[-1] = 0.0
133
+ return float(np.max(np.abs(u - np.exp(-np.pi**2 * t_end) * np.sin(np.pi * x))))
134
+
135
+
136
+ def _dirichlet_case(boundary_in_system: bool):
137
+ ns = (11, 21, 41, 81)
138
+ return lambda: check_order([1.0 / (n - 1) for n in ns], [_cn_dirichlet_error(n, boundary_in_system) for n in ns], 2.0)
139
+
140
+
141
+ def _neumann_heat_change(n: int, mirror: bool) -> float:
142
+ """Relative change of the total heat for u_t = u_xx on [0, 1] with insulated ends (explicit, 400 steps).
143
+
144
+ Control: edge nodes updated with a mirrored ghost value, total heat by the trapezoid rule (exactly
145
+ conserved). Bug: interior update, then u[0] = u[1], u[-1] = u[-2], total heat as sum(u) dx.
146
+ """
147
+ x = np.linspace(0.0, 1.0, n)
148
+ dx = x[1] - x[0]
149
+ dt = 0.25 * dx**2
150
+ u = np.exp(-((x - 0.3) ** 2) / 0.01)
151
+
152
+ def total(v):
153
+ w = np.full(n, dx)
154
+ if mirror:
155
+ w[[0, -1]] = dx / 2
156
+ return float(np.sum(w * v))
157
+
158
+ start = total(u)
159
+ for _ in range(400):
160
+ new = u.copy()
161
+ new[1:-1] = u[1:-1] + dt / dx**2 * (u[2:] - 2 * u[1:-1] + u[:-2])
162
+ if mirror:
163
+ new[0] = u[0] + dt / dx**2 * 2 * (u[1] - u[0])
164
+ new[-1] = u[-1] + dt / dx**2 * 2 * (u[-2] - u[-1])
165
+ else:
166
+ new[0], new[-1] = new[1], new[-2]
167
+ u = new
168
+ return (total(u) - start) / start
169
+
170
+
171
+ def _source_gain(n: int, times_dt: bool) -> float:
172
+ """Gain of u under a constant source S = 2 over T = 1 with no diffusion, in n steps; exactly S T = 2."""
173
+ dt, u = 1.0 / n, 0.0
174
+ for _ in range(n):
175
+ u += 2.0 * dt if times_dt else 2.0
176
+ return u
177
+
178
+
179
+ def _well_energy_error(n: int, spacing_from_intervals: bool) -> float:
180
+ """Lowest level of the infinite square well [0, 2] (hbar = m = 1) with n - 1 interior points.
181
+
182
+ n - 1 interior points split the well into n intervals, so dx = W/n; the bug uses W/(n - 1).
183
+ """
184
+ W = 2.0
185
+ dx = W / n if spacing_from_intervals else W / (n - 1)
186
+ m = n - 1
187
+ H = (np.diag(np.full(m, 1.0 / dx**2)) + np.diag(np.full(m - 1, -0.5 / dx**2), 1)
188
+ + np.diag(np.full(m - 1, -0.5 / dx**2), -1))
189
+ return float(abs(np.linalg.eigvalsh(H)[0] - np.pi**2 / (2 * W**2)))
190
+
191
+
192
+ def _spacing_case(spacing_from_intervals: bool):
193
+ ns = (50, 100, 200, 400)
194
+ return lambda: check_order([2.0 / n for n in ns], [_well_energy_error(n, spacing_from_intervals) for n in ns], 2.0)
195
+
196
+
103
197
  @dataclass(frozen=True)
104
198
  class MethodCase:
105
199
  name: str
@@ -137,6 +231,27 @@ CASES = [
137
231
  MethodCase("spectral_even_only", True, "wavenumbers built from two halves of the array (fails for odd n)", "G10",
138
232
  "studies.size_sweep_check", lambda: size_sweep_check(_spectral_derivative(True)),
139
233
  missed_by="any study on power-of-two grids"),
234
+ MethodCase("dirichlet_ok", False, "Crank-Nicolson solving only for interior nodes, Dirichlet values in the system",
235
+ "-", "order.check_order", _dirichlet_case(True)),
236
+ MethodCase("dirichlet_after_solve", True,
237
+ "Crank-Nicolson matrix over all nodes, Dirichlet values written only after each solve", "H01",
238
+ "order.check_order", _dirichlet_case(False),
239
+ missed_by="a single run compared with a loose tolerance (the error is small but does not shrink)"),
240
+ MethodCase("neumann_ok", False, "insulated ends with a mirrored ghost value, trapezoid total", "-",
241
+ "studies.balance_check",
242
+ lambda: balance_check(lambda n: _neumann_heat_change(n, True), 0.0, (21, 41, 81), tol=1e-12)),
243
+ MethodCase("neumann_by_copy", True, "insulated ends made by copying the neighbour value after the update", "H54",
244
+ "studies.balance_check",
245
+ lambda: balance_check(lambda n: _neumann_heat_change(n, False), 0.0, (21, 41, 81), tol=1e-12),
246
+ missed_by="a one-step test with the heat away from the walls"),
247
+ MethodCase("source_ok", False, "source term multiplied by dt", "-", "studies.balance_check",
248
+ lambda: balance_check(lambda n: _source_gain(n, True), 2.0, (10, 100), tol=1e-12)),
249
+ MethodCase("source_without_dt", True, "source term added once per step without the factor dt", "H01",
250
+ "studies.balance_check", lambda: balance_check(lambda n: _source_gain(n, False), 2.0, (10, 100), tol=1e-12),
251
+ missed_by="any test with zero source"),
252
+ MethodCase("spacing_ok", False, "n - 1 interior points with dx = W/n", "-", "order.check_order", _spacing_case(True)),
253
+ MethodCase("spacing_off_by_one", True, "n - 1 interior points with dx = W/(n - 1)", "H19", "order.check_order",
254
+ _spacing_case(False), missed_by="a single comparison at n = 100 with a 5% tolerance"),
140
255
  ]
141
256
 
142
257
 
@@ -0,0 +1,128 @@
1
+ """Exact reference solutions that testing public codes needed again and again.
2
+
3
+ Each one was written by hand in several adapters before it moved here (repository IDs as in
4
+ results/corpus/SUMMARY.md). All are numpy-only and vectorised where that is natural.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ import numpy as np
12
+
13
+ # --- statistical mechanics ---------------------------------------------------------------------------
14
+
15
+
16
+ def ising2d_exact(L: int, T: float, J: float = 1.0, h: float = 0.0) -> dict:
17
+ """Exact thermal averages of the periodic L x L Ising model by enumerating all 2^(L*L) states.
18
+
19
+ H = -J sum_<ij> s_i s_j - h sum_i s_i, k_B = 1. Returns per-site <e>, <|m|>, <m^2>, <m^4>, the
20
+ specific heat per site and Z. Practical up to L = 4 (65536 states); used for D04, H06, H55.
21
+ """
22
+ n = L * L
23
+ if n > 20:
24
+ raise ValueError("enumeration is limited to L*L <= 20 sites (L <= 4)")
25
+ states = ((np.arange(2**n)[:, None] >> np.arange(n)) & 1) * 2 - 1
26
+ s = states.reshape(-1, L, L)
27
+ bonds = np.sum(s * np.roll(s, 1, axis=1), axis=(1, 2)) + np.sum(s * np.roll(s, 1, axis=2), axis=(1, 2))
28
+ M = s.sum(axis=(1, 2))
29
+ E = -J * bonds - h * M
30
+ w = np.exp(-(E - E.min()) / T)
31
+ Z = w.sum()
32
+ avg = lambda q: float(np.sum(w * q) / Z) # noqa: E731
33
+ e, e2 = avg(E) / n, avg(E**2) / n**2
34
+ return {"e": e, "m_abs": avg(np.abs(M)) / n, "m2": avg(M**2) / n**2, "m4": avg(M**4) / n**4,
35
+ "c": n * (e2 - e * e) / T**2, "Z": float(Z * np.exp(-E.min() / T))}
36
+
37
+
38
+ def langevin(x):
39
+ """L(x) = coth x - 1/x, with the series near 0."""
40
+ x = np.asarray(x, dtype=float)
41
+ small = np.abs(x) < 1e-3
42
+ safe = np.where(small, 1.0, x)
43
+ return np.where(small, x / 3 - x**3 / 45, 1 / np.tanh(safe) - 1 / safe)
44
+
45
+
46
+ def heisenberg_open_chain(N: int, J: float, T: float) -> dict:
47
+ """Classical unit-vector Heisenberg chain with open ends, H = -J sum S_i.S_{i+1}: exact per-site
48
+ energy and specific heat (each bond is independent). Used for H64."""
49
+ b = J / T
50
+ e = -(N - 1) * J * float(langevin(b)) / N
51
+ c = (N - 1) / N * (1 - b * b / math.sinh(b) ** 2)
52
+ return {"e": e, "c": c}
53
+
54
+
55
+ # --- diffusion and transport -------------------------------------------------------------------------
56
+
57
+
58
+ def heat_sine_mode(x, t, L: float = 1.0, kappa: float = 1.0, mode: int = 1):
59
+ """u_t = kappa u_xx on [0, L], u = 0 at both ends, u(x, 0) = sin(mode pi x / L)."""
60
+ k = mode * np.pi / L
61
+ return np.exp(-kappa * k * k * t) * np.sin(k * np.asarray(x, dtype=float))
62
+
63
+
64
+ def advection_diffusion_gaussian(x, t, x0: float, sigma0: float, v: float = 0.0, D: float = 0.0):
65
+ """Gaussian of peak 1 and width sigma0 at x0, carried at speed v and spread by D on the whole line:
66
+ sigma^2 = sigma0^2 + 2 D t, peak sigma0/sigma. Used for H25."""
67
+ s = math.sqrt(sigma0**2 + 2 * D * t)
68
+ return sigma0 / s * np.exp(-((np.asarray(x, dtype=float) - x0 - v * t) ** 2) / (2 * s * s))
69
+
70
+
71
+ def semi_infinite_step(x, t, alpha: float, T0: float, Ts: float):
72
+ """Semi-infinite solid at T0, surface held at Ts from t = 0: T = Ts + (T0 - Ts) erf(x / 2 sqrt(alpha t))."""
73
+ x = np.asarray(x, dtype=float)
74
+ return Ts + (T0 - Ts) * np.vectorize(math.erf)(x / (2 * math.sqrt(alpha * t)))
75
+
76
+
77
+ # --- mechanics ---------------------------------------------------------------------------------------
78
+
79
+
80
+ def double_pendulum_acc(th1, w1, th2, w2, m1, m2, l1, l2, g=9.81):
81
+ """Angular accelerations of a double pendulum (point masses, massless rods, angles from the downward
82
+ vertical), from the Lagrangian mass matrix. Used for D01, E01, H03, H27."""
83
+ d = th1 - th2
84
+ M = np.array([[(m1 + m2) * l1**2, m2 * l1 * l2 * np.cos(d)], [m2 * l1 * l2 * np.cos(d), m2 * l2**2]])
85
+ r = np.array([-m2 * l1 * l2 * w2**2 * np.sin(d) - (m1 + m2) * g * l1 * np.sin(th1),
86
+ m2 * l1 * l2 * w1**2 * np.sin(d) - m2 * g * l2 * np.sin(th2)])
87
+ return np.linalg.solve(M, r)
88
+
89
+
90
+ def double_pendulum_energy(th1, w1, th2, w2, m1, m2, l1, l2, g=9.81):
91
+ v1sq = (l1 * w1) ** 2
92
+ v2sq = v1sq + (l2 * w2) ** 2 + 2 * l1 * l2 * w1 * w2 * np.cos(th1 - th2)
93
+ return 0.5 * m1 * v1sq + 0.5 * m2 * v2sq - (m1 + m2) * g * l1 * np.cos(th1) - m2 * g * l2 * np.cos(th2)
94
+
95
+
96
+ def cantilever_tip(P: float, L: float, E: float, I: float) -> tuple[float, float]:
97
+ """Euler-Bernoulli cantilever with an end load: tip deflection P L^3/(3EI) and rotation P L^2/(2EI)."""
98
+ return P * L**3 / (3 * E * I), P * L**2 / (2 * E * I)
99
+
100
+
101
+ # --- quantum mechanics -------------------------------------------------------------------------------
102
+
103
+
104
+ def infinite_well_energy(n, W: float, hbar: float = 1.0, m: float = 1.0):
105
+ """E_n = n^2 pi^2 hbar^2 / (2 m W^2), n = 1, 2, ... Used for H19."""
106
+ n = np.asarray(n, dtype=float)
107
+ return n * n * np.pi**2 * hbar**2 / (2 * m * W * W)
108
+
109
+
110
+ def barrier_transmission(E, V: float, a: float, hbar: float = 1.0, m: float = 1.0):
111
+ """Transmission probability through a square barrier of height V and width a (E != V). Used for H40."""
112
+ E = np.asarray(E, dtype=float)
113
+ k = np.sqrt(2 * m * np.abs(V - E)) / hbar * a
114
+ s2 = np.where(E < V, np.sinh(k) ** 2, np.sin(k) ** 2)
115
+ return 1 / (1 + V * V * s2 / (4 * E * np.abs(V - E)))
116
+
117
+
118
+ def tfim_open_ground_energy(N: int, J: float, h: float) -> float:
119
+ """Ground energy of the open transverse-field Ising chain H = J sum Z_i Z_{i+1} + h sum X_i, from the
120
+ free-fermion (Jordan-Wigner, Bogoliubov-de Gennes) solution; the sign of J does not matter. Used for H22."""
121
+ A = np.zeros((N, N))
122
+ B = np.zeros((N, N))
123
+ np.fill_diagonal(A, 2 * h)
124
+ for i in range(N - 1):
125
+ A[i, i + 1] = A[i + 1, i] = J
126
+ B[i, i + 1], B[i + 1, i] = J, -J
127
+ eps = np.linalg.eigvalsh(np.block([[A, B], [-B, -A]]))
128
+ return float(-0.5 * np.sum(eps[eps > 0]))
@@ -186,6 +186,32 @@ def backward_mms_study(integrate_back: Callable, mms: ManufacturedODE, t_end: fl
186
186
  return check_order(hs, errs, expected_order, **kw)
187
187
 
188
188
 
189
+ def balance_check(measure: Callable, expected: float, resolutions, *, tol: float = 1e-9, label: str = "") -> Result:
190
+ """Check an exact discrete balance at every resolution: `measure(n)` must equal `expected` to `tol`.
191
+
192
+ A conservative scheme keeps a budget exactly on every grid: the total heat with
193
+ insulated walls, the heat gained from a known boundary flux or source over a run.
194
+ Three real codes failed such a budget while still converging: a zero-flux wall
195
+ made by copying the neighbour value lost 8.5% of the heat, a source added
196
+ without the factor dt depended on the number of steps, and boundary nodes
197
+ given full cells took in 7% too much heat. A convergence study alone can pass
198
+ these, so the check reports whether the mismatch shrinks with resolution.
199
+ """
200
+ rows = []
201
+ for n in resolutions:
202
+ rows.append((n, float(measure(n))))
203
+ bad = [(n, v) for n, v in rows if not abs(v - expected) <= tol]
204
+ prefix = f"{label}: " if label else ""
205
+ listing = ", ".join(f"n={n}: {v:.6g}" for n, v in rows)
206
+ if not bad:
207
+ return Result(PASS, f"{prefix}balance {expected:.6g} kept to {tol:g} at every resolution ({listing})")
208
+ gaps = [abs(v - expected) for _, v in rows]
209
+ shrinking = len(gaps) > 1 and all(b < a for a, b in zip(gaps, gaps[1:]))
210
+ hint = ("; the mismatch shrinks with resolution, so the scheme converges but does not conserve"
211
+ if shrinking else "")
212
+ return Result(FAIL, f"{prefix}expected {expected:.6g}, got {listing}{hint}")
213
+
214
+
189
215
  def size_sweep_check(run_at_size: Callable, sizes=(15, 16, 31, 32, 63, 64), *, label: str = "") -> Result:
190
216
  """Call `run_at_size(n)` for odd and even sizes; FAIL if it raises for some of them.
191
217
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: physverify
3
- Version: 0.2.0
3
+ Version: 0.3.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.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.
42
+ Status: early version (0.3.0). It has been tried on 68 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
 
@@ -51,7 +51,7 @@ physverify uses a few standard ideas from numerical analysis:
51
51
 
52
52
  1. **Make up an answer, then check the code finds it.** Pick a smooth function, work out what extra push (a "source term") would make it an exact solution, give that push to your code, and see if your code reproduces the function. This is called the *Method of Manufactured Solutions*.
53
53
  2. **Check the error shrinks at the right speed.** If your method is supposed to be 4th order, halving the time step should make the error about 16 times smaller. If it only gets 2 times smaller, something is wrong, even if the error looked small.
54
- 3. **Check conserved quantities.** Energy, mass or momentum should stay constant (or drift in a predictable way).
54
+ 3. **Check conserved quantities and budgets.** Energy, mass or momentum should stay constant (or drift in a predictable way), and heat added through a wall or a source should match what went in, exactly, on every grid.
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
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").
@@ -74,12 +74,14 @@ The bugs and the checks were written by the same person, so these numbers show t
74
74
 
75
75
  ## Trying it on other people's code
76
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.
77
+ To see what it does on code it was not written for, public Python physics repositories from GitHub were run in a locked-down container (October 2026): no network, the repository read-only, nothing from it run on the author's computer. 40 were drawn at random from topic searches (batches 1 to 4); another 60 were the newest small repositories whose README says the work is complete (batch 5), and 20 more were picked because they are quick to test (batch 6); batches 5 and 6 are not random.
78
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.
79
+ - **68 of the 120 could be tested.** Most of the others were not numerical solvers (neural networks, apps and games, analysis tools, finance), one needed a recent NVIDIA GPU, and a few need more set-up.
80
+ - **16 confirmed bugs in 12 of the 68.** 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; a spectral derivative that crashes for an odd number of grid points; boundary values applied after the linear solve instead of inside it; an insulated wall that leaks heat; a grid spacing off by one interval; a Kepler solver that fails for negative angles.
81
+ - **The rest had no confirmed bug.** Most passed every check that applied, often to 1e-13 or better.
82
+ - **Our own adapters were wrong about 28 times** before checking (a wrong constant, too-coarse steps, a wrong sign convention, and so on). Every FAIL was checked by hand before being called a bug, and these mistakes are recorded, not hidden.
83
+
84
+ These counts describe the repositories that were drawn; they are not a bug rate for physics code in general.
83
85
 
84
86
  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
87
 
@@ -120,7 +122,7 @@ pytest my_spec.py # each check becomes a pytest tes
120
122
 
121
123
  ## More
122
124
 
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.
125
+ - [results/corpus/SUMMARY.md](https://github.com/rcgiri-physics/physverify/blob/main/results/corpus/SUMMARY.md): the 120 public repositories, what was tested and what was found.
124
126
  - [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.
125
127
  - [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).
126
128
  - [docs/technical.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md): full details, benchmark tables, limits and related work.
@@ -15,6 +15,7 @@ src/physverify/method_bugs.py
15
15
  src/physverify/mms.py
16
16
  src/physverify/order.py
17
17
  src/physverify/pytest_plugin.py
18
+ src/physverify/references.py
18
19
  src/physverify/report.py
19
20
  src/physverify/steppers.py
20
21
  src/physverify/studies.py
@@ -58,5 +59,6 @@ tests/test_order.py
58
59
  tests/test_properties.py
59
60
  tests/test_real_projects.py
60
61
  tests/test_reference_problems.py
62
+ tests/test_references.py
61
63
  tests/test_seeded_bugs.py
62
64
  tests/test_studies.py
@@ -143,3 +143,48 @@ def test_load_definitions_stubs_missing_imports(tmp_path):
143
143
  src.write_text("import not_installed_plotting_pkg\nimport math\ndef f(x):\n return math.sqrt(x)\n")
144
144
  mod = load_definitions_from_path("sim_stub", src, stubs=("not_installed_plotting_pkg",))
145
145
  assert mod.f(4.0) == 2.0
146
+
147
+
148
+ def test_balance_check_catches_a_converging_but_non_conservative_scheme():
149
+ from physverify.studies import balance_check
150
+
151
+ # a budget that is wrong by 1/n: converges, never conserves (the heat-loss pattern of a real code)
152
+ r = balance_check(lambda n: 1.0 / n, 0.0, (10, 20, 40), tol=1e-12)
153
+ assert r.status == FAIL and "converges but does not conserve" in r.message
154
+ # exact at every resolution
155
+ assert balance_check(lambda n: 2.0, 2.0, (10, 100)).status == PASS
156
+ # wrong and growing with n (a source added without dt): FAIL without the converging hint
157
+ r = balance_check(lambda n: 2.0 * n, 2.0, (10, 100))
158
+ assert r.status == FAIL and "converges" not in r.message
159
+
160
+
161
+ def test_stub_modules_provide_names_and_keep_decorated_functions():
162
+ import sys
163
+
164
+ from physverify.loaders import Placeholder, stub_modules
165
+
166
+ names = stub_modules({"no_such_pkg_xyz.metrics": ["score"], "no_such_web_xyz": ["Flask"]})
167
+ try:
168
+ from no_such_pkg_xyz.metrics import score # noqa: F401 (a parent package is created too)
169
+ import no_such_web_xyz
170
+
171
+ app = no_such_web_xyz.Flask(__name__)
172
+
173
+ @app.route("/x", methods=["POST"])
174
+ def view():
175
+ return 42
176
+
177
+ assert view() == 42 and isinstance(score, Placeholder)
178
+ assert stub_modules(["math"]) == [] # a module that imports for real is left alone
179
+ finally:
180
+ for n in names:
181
+ sys.modules.pop(n, None)
182
+
183
+
184
+ def test_run_script_returns_globals_and_output(tmp_path):
185
+ from physverify.loaders import run_script
186
+
187
+ src = tmp_path / "demo.py"
188
+ src.write_text("import sys\nx = 6 * 7\nprint('hello', sys.argv[1:])\n")
189
+ g, out = run_script(src, argv=["a"])
190
+ assert g["x"] == 42 and "hello ['a']" in out
@@ -0,0 +1,103 @@
1
+ """Each reference in physverify.references against an independent computation."""
2
+
3
+ import itertools
4
+ import math
5
+
6
+ import numpy as np
7
+
8
+ from physverify import references as R
9
+
10
+
11
+ def test_ising_enumeration_matches_a_plain_loop():
12
+ L, T, J, h = 3, 2.2, 0.8, 0.3
13
+ Z = Es = Ms = 0.0
14
+ for spins in itertools.product((-1, 1), repeat=L * L):
15
+ s = np.array(spins).reshape(L, L)
16
+ E = 0.0
17
+ for i in range(L):
18
+ for j in range(L):
19
+ E -= J * s[i, j] * (s[(i + 1) % L, j] + s[i, (j + 1) % L]) + h * s[i, j]
20
+ w = math.exp(-E / T)
21
+ Z, Es, Ms = Z + w, Es + w * E, Ms + w * abs(s.sum())
22
+ r = R.ising2d_exact(L, T, J, h)
23
+ assert abs(r["e"] - Es / Z / L**2) < 1e-12
24
+ assert abs(r["m_abs"] - Ms / Z / L**2) < 1e-12
25
+ assert abs(r["Z"] - Z) / Z < 1e-12
26
+
27
+
28
+ def test_ising_specific_heat_is_the_temperature_derivative_of_energy():
29
+ dT = 1e-4
30
+ e = lambda T: R.ising2d_exact(4, T)["e"] # noqa: E731
31
+ c_fd = (e(2.3 + dT) - e(2.3 - dT)) / (2 * dT)
32
+ assert abs(R.ising2d_exact(4, 2.3)["c"] - c_fd) < 1e-6
33
+
34
+
35
+ def test_heisenberg_chain_bond_energy_by_quadrature():
36
+ # one bond: <cos theta> under exp(b cos theta) on the sphere
37
+ b = 1.7
38
+ z = np.linspace(-1, 1, 200001)
39
+ w = np.exp(b * z)
40
+ mean = np.trapezoid(z * w, z) / np.trapezoid(w, z)
41
+ r = R.heisenberg_open_chain(5, 1.7, 1.0)
42
+ assert abs(r["e"] - (-(4 / 5) * 1.7 * mean)) < 1e-9 # -(N-1)/N J <cos>
43
+ assert abs(float(R.langevin(1e-5)) - 1e-5 / 3) < 1e-15
44
+
45
+
46
+ def test_heat_mode_and_gaussian_satisfy_their_equations():
47
+ x, t, h = 0.37, 0.05, 1e-4
48
+ u = lambda x, t: R.heat_sine_mode(x, t, L=2.0, kappa=0.7, mode=3) # noqa: E731
49
+ ut = (u(x, t + h) - u(x, t - h)) / (2 * h)
50
+ uxx = (u(x + h, t) - 2 * u(x, t) + u(x - h, t)) / h**2
51
+ assert abs(ut - 0.7 * uxx) < 1e-5
52
+ g = lambda x, t: R.advection_diffusion_gaussian(x, t, 1.0, 0.5, v=0.3, D=0.2) # noqa: E731
53
+ x, t = 1.4, 2.0
54
+ gt = (g(x, t + h) - g(x, t - h)) / (2 * h)
55
+ gx = (g(x + h, t) - g(x - h, t)) / (2 * h)
56
+ gxx = (g(x + h, t) - 2 * g(x, t) + g(x - h, t)) / h**2
57
+ assert abs(gt + 0.3 * gx - 0.2 * gxx) < 1e-5
58
+
59
+
60
+ def test_double_pendulum_conserves_its_energy():
61
+ p = (0.7, 1.9, 1.3, 0.6, 9.81)
62
+ y = np.array([2.0, 0.0, -0.5, 0.3])
63
+
64
+ def f(y):
65
+ a = R.double_pendulum_acc(y[0], y[1], y[2], y[3], *p)
66
+ return np.array([y[1], a[0], y[3], a[1]])
67
+
68
+ E0 = R.double_pendulum_energy(y[0], y[1], y[2], y[3], *p)
69
+ dt = 1e-3
70
+ for _ in range(2000):
71
+ k1 = f(y); k2 = f(y + dt / 2 * k1); k3 = f(y + dt / 2 * k2); k4 = f(y + dt * k3)
72
+ y = y + dt / 6 * (k1 + 2 * k2 + 2 * k3 + k4)
73
+ assert abs(R.double_pendulum_energy(y[0], y[1], y[2], y[3], *p) - E0) < 1e-6 * abs(E0) # RK4, dt = 1e-3, 2 s: drift about 5e-8
74
+
75
+
76
+ def test_quantum_references():
77
+ # infinite well against a fine finite-difference matrix
78
+ n, W = 400, 2.0
79
+ dx = W / n
80
+ H = np.diag(np.full(n - 1, 1 / dx**2)) + np.diag(np.full(n - 2, -0.5 / dx**2), 1) + np.diag(np.full(n - 2, -0.5 / dx**2), -1)
81
+ assert abs(np.linalg.eigvalsh(H)[0] / R.infinite_well_energy(1, W) - 1) < 1e-4
82
+ # barrier: unitarity-free textbook limit, T -> 1 for V -> 0 and the E < V tunnelling formula
83
+ assert abs(R.barrier_transmission(0.7, 1e-9, 2.0) - 1) < 1e-8
84
+ k = math.sqrt(2 * 0.3) * 2.0
85
+ assert abs(R.barrier_transmission(0.7, 1.0, 2.0) - 1 / (1 + math.sinh(k) ** 2 / (4 * 0.7 * 0.3))) < 1e-14
86
+ # TFIM against dense diagonalisation
87
+ N, J, h = 6, 1.1, 0.7
88
+ X, Z, I = np.array([[0, 1], [1, 0]]), np.diag([1, -1]), np.eye(2)
89
+
90
+ def op(d):
91
+ out = np.ones((1, 1))
92
+ for q in range(N):
93
+ out = np.kron(out, d.get(q, I))
94
+ return out
95
+
96
+ Hd = sum(J * op({i: Z, i + 1: Z}) for i in range(N - 1)) + sum(h * op({i: X}) for i in range(N))
97
+ assert abs(np.linalg.eigvalsh(Hd)[0] - R.tfim_open_ground_energy(N, J, h)) < 1e-10
98
+ assert R.cantilever_tip(10.0, 5.0, 2e8, 1e-4)[0] == 10.0 * 125.0 / (3 * 2e8 * 1e-4)
99
+
100
+
101
+ def test_semi_infinite_step_limits():
102
+ assert abs(R.semi_infinite_step(0.0, 1.0, 1e-5, 300.0, 1000.0) - 1000.0) < 1e-12
103
+ assert abs(R.semi_infinite_step(1.0, 1.0, 1e-5, 300.0, 1000.0) - 300.0) < 1e-9
@@ -1,94 +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 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
File without changes
File without changes
File without changes
File without changes