physverify 0.1.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.
- {physverify-0.1.0/src/physverify.egg-info → physverify-0.3.0}/PKG-INFO +20 -4
- {physverify-0.1.0 → physverify-0.3.0}/README.md +19 -3
- {physverify-0.1.0 → physverify-0.3.0}/pyproject.toml +1 -1
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/__init__.py +1 -1
- physverify-0.3.0/src/physverify/butcher.py +111 -0
- physverify-0.3.0/src/physverify/constants.py +121 -0
- physverify-0.3.0/src/physverify/loaders.py +178 -0
- physverify-0.3.0/src/physverify/method_bugs.py +266 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/order.py +15 -6
- physverify-0.3.0/src/physverify/references.py +128 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/ising.py +5 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/sampler.py +3 -2
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/studies.py +90 -0
- {physverify-0.1.0 → physverify-0.3.0/src/physverify.egg-info}/PKG-INFO +20 -4
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify.egg-info/SOURCES.txt +7 -0
- physverify-0.3.0/tests/test_corpus_lessons.py +190 -0
- physverify-0.3.0/tests/test_method_bugs.py +21 -0
- physverify-0.3.0/tests/test_references.py +103 -0
- physverify-0.1.0/src/physverify/loaders.py +0 -49
- {physverify-0.1.0 → physverify-0.3.0}/LICENSE +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/setup.cfg +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/__main__.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/__init__.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/double_pendulum.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/lorenz.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/projectile_v1.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/projectile_v2.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/three_body.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/agent.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/checks.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/cli.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/compare.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/matrix.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/mms.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/__init__.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/_common.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/heat1d.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/kepler.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/pendulum.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/projectile.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/sod.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/pytest_plugin.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/report.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/steppers.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/__init__.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/calibrate.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/checks.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/exact.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/matrix.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/reference.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/stats.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify/symbolic.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify.egg-info/dependency_links.txt +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify.egg-info/entry_points.txt +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify.egg-info/requires.txt +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/src/physverify.egg-info/top_level.txt +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/tests/test_agent.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/tests/test_cli.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/tests/test_ising.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/tests/test_mms.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/tests/test_new_problems.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/tests/test_order.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/tests/test_properties.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/tests/test_real_projects.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/tests/test_reference_problems.py +0 -0
- {physverify-0.1.0 → physverify-0.3.0}/tests/test_seeded_bugs.py +0 -0
- {physverify-0.1.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.
|
|
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.
|
|
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,9 +51,10 @@ 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
|
+
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,23 @@ To test it, 37 bugs were added one at a time to five small solvers (projectile,
|
|
|
67
68
|
- **0 false alarms** on the bug-free versions.
|
|
68
69
|
- The 2 missed bugs are explained in the [technical details](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md).
|
|
69
70
|
|
|
70
|
-
For the Monte Carlo checks, all
|
|
71
|
+
For the Monte Carlo checks, all 21 seeded bugs were caught with no false alarms.
|
|
71
72
|
|
|
72
73
|
The bugs and the checks were written by the same person, so these numbers show the tool works as designed. They are not a promise about bugs in other people's code.
|
|
73
74
|
|
|
75
|
+
## Trying it on other people's code
|
|
76
|
+
|
|
77
|
+
To see what it does on code it was not written for, 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
|
+
|
|
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.
|
|
85
|
+
|
|
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).
|
|
87
|
+
|
|
74
88
|
## Try it
|
|
75
89
|
|
|
76
90
|
You need Python 3.10 or newer.
|
|
@@ -108,6 +122,8 @@ pytest my_spec.py # each check becomes a pytest tes
|
|
|
108
122
|
|
|
109
123
|
## More
|
|
110
124
|
|
|
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.
|
|
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.
|
|
111
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).
|
|
112
128
|
- [docs/technical.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md): full details, benchmark tables, limits and related work.
|
|
113
129
|
- [docs/patch.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/patch.md): what the exact "patch" checks assume.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**A tool that checks whether a physics simulation is solving the right equations correctly, not just running without errors.**
|
|
4
4
|
|
|
5
|
-
Status: early version (0.
|
|
5
|
+
Status: early version (0.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,9 +14,10 @@ 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
|
+
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,23 @@ To test it, 37 bugs were added one at a time to five small solvers (projectile,
|
|
|
30
31
|
- **0 false alarms** on the bug-free versions.
|
|
31
32
|
- The 2 missed bugs are explained in the [technical details](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md).
|
|
32
33
|
|
|
33
|
-
For the Monte Carlo checks, all
|
|
34
|
+
For the Monte Carlo checks, all 21 seeded bugs were caught with no false alarms.
|
|
34
35
|
|
|
35
36
|
The bugs and the checks were written by the same person, so these numbers show the tool works as designed. They are not a promise about bugs in other people's code.
|
|
36
37
|
|
|
38
|
+
## Trying it on other people's code
|
|
39
|
+
|
|
40
|
+
To see what it does on code it was not written for, 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
|
+
|
|
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.
|
|
48
|
+
|
|
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).
|
|
50
|
+
|
|
37
51
|
## Try it
|
|
38
52
|
|
|
39
53
|
You need Python 3.10 or newer.
|
|
@@ -71,6 +85,8 @@ pytest my_spec.py # each check becomes a pytest tes
|
|
|
71
85
|
|
|
72
86
|
## More
|
|
73
87
|
|
|
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.
|
|
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.
|
|
74
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).
|
|
75
91
|
- [docs/technical.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/technical.md): full details, benchmark tables, limits and related work.
|
|
76
92
|
- [docs/patch.md](https://github.com/rcgiri-physics/physverify/blob/main/docs/patch.md): what the exact "patch" checks assume.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "physverify"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.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"
|
|
@@ -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,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
|