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.
Files changed (67) hide show
  1. {physverify-0.1.0/src/physverify.egg-info → physverify-0.3.0}/PKG-INFO +20 -4
  2. {physverify-0.1.0 → physverify-0.3.0}/README.md +19 -3
  3. {physverify-0.1.0 → physverify-0.3.0}/pyproject.toml +1 -1
  4. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/__init__.py +1 -1
  5. physverify-0.3.0/src/physverify/butcher.py +111 -0
  6. physverify-0.3.0/src/physverify/constants.py +121 -0
  7. physverify-0.3.0/src/physverify/loaders.py +178 -0
  8. physverify-0.3.0/src/physverify/method_bugs.py +266 -0
  9. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/order.py +15 -6
  10. physverify-0.3.0/src/physverify/references.py +128 -0
  11. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/ising.py +5 -0
  12. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/sampler.py +3 -2
  13. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/studies.py +90 -0
  14. {physverify-0.1.0 → physverify-0.3.0/src/physverify.egg-info}/PKG-INFO +20 -4
  15. {physverify-0.1.0 → physverify-0.3.0}/src/physverify.egg-info/SOURCES.txt +7 -0
  16. physverify-0.3.0/tests/test_corpus_lessons.py +190 -0
  17. physverify-0.3.0/tests/test_method_bugs.py +21 -0
  18. physverify-0.3.0/tests/test_references.py +103 -0
  19. physverify-0.1.0/src/physverify/loaders.py +0 -49
  20. {physverify-0.1.0 → physverify-0.3.0}/LICENSE +0 -0
  21. {physverify-0.1.0 → physverify-0.3.0}/setup.cfg +0 -0
  22. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/__main__.py +0 -0
  23. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/__init__.py +0 -0
  24. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/double_pendulum.py +0 -0
  25. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/lorenz.py +0 -0
  26. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/projectile_v1.py +0 -0
  27. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/projectile_v2.py +0 -0
  28. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/adapters/three_body.py +0 -0
  29. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/agent.py +0 -0
  30. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/checks.py +0 -0
  31. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/cli.py +0 -0
  32. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/compare.py +0 -0
  33. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/matrix.py +0 -0
  34. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/mms.py +0 -0
  35. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/__init__.py +0 -0
  36. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/_common.py +0 -0
  37. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/heat1d.py +0 -0
  38. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/kepler.py +0 -0
  39. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/pendulum.py +0 -0
  40. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/projectile.py +0 -0
  41. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/problems/sod.py +0 -0
  42. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/pytest_plugin.py +0 -0
  43. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/report.py +0 -0
  44. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/steppers.py +0 -0
  45. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/__init__.py +0 -0
  46. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/calibrate.py +0 -0
  47. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/checks.py +0 -0
  48. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/exact.py +0 -0
  49. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/matrix.py +0 -0
  50. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/reference.py +0 -0
  51. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/stochastic/stats.py +0 -0
  52. {physverify-0.1.0 → physverify-0.3.0}/src/physverify/symbolic.py +0 -0
  53. {physverify-0.1.0 → physverify-0.3.0}/src/physverify.egg-info/dependency_links.txt +0 -0
  54. {physverify-0.1.0 → physverify-0.3.0}/src/physverify.egg-info/entry_points.txt +0 -0
  55. {physverify-0.1.0 → physverify-0.3.0}/src/physverify.egg-info/requires.txt +0 -0
  56. {physverify-0.1.0 → physverify-0.3.0}/src/physverify.egg-info/top_level.txt +0 -0
  57. {physverify-0.1.0 → physverify-0.3.0}/tests/test_agent.py +0 -0
  58. {physverify-0.1.0 → physverify-0.3.0}/tests/test_cli.py +0 -0
  59. {physverify-0.1.0 → physverify-0.3.0}/tests/test_ising.py +0 -0
  60. {physverify-0.1.0 → physverify-0.3.0}/tests/test_mms.py +0 -0
  61. {physverify-0.1.0 → physverify-0.3.0}/tests/test_new_problems.py +0 -0
  62. {physverify-0.1.0 → physverify-0.3.0}/tests/test_order.py +0 -0
  63. {physverify-0.1.0 → physverify-0.3.0}/tests/test_properties.py +0 -0
  64. {physverify-0.1.0 → physverify-0.3.0}/tests/test_real_projects.py +0 -0
  65. {physverify-0.1.0 → physverify-0.3.0}/tests/test_reference_problems.py +0 -0
  66. {physverify-0.1.0 → physverify-0.3.0}/tests/test_seeded_bugs.py +0 -0
  67. {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.1.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.1.0). 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,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 20 seeded bugs were caught with no false alarms.
71
+ For the Monte Carlo checks, all 21 seeded bugs were caught with no false alarms.
71
72
 
72
73
  The bugs and the checks were written by the same person, so these numbers show the tool works as designed. They are not a promise about bugs in other people's code.
73
74
 
75
+ ## Trying it on other people's code
76
+
77
+ To see what it does on code it was not written for, 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.1.0). 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,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 20 seeded bugs were caught with no false alarms.
34
+ For the Monte Carlo checks, all 21 seeded bugs were caught with no false alarms.
34
35
 
35
36
  The bugs and the checks were written by the same person, so these numbers show the tool works as designed. They are not a promise about bugs in other people's code.
36
37
 
38
+ ## Trying it on other people's code
39
+
40
+ To see what it does on code it was not written for, 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.1.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.1.0"
9
+ __version__ = "0.3.0"
10
10
 
11
11
  __all__ = [
12
12
  "Check",
@@ -0,0 +1,111 @@
1
+ """Order of a Runge-Kutta method from its Butcher table, by the order conditions.
2
+
3
+ A Runge-Kutta method (A, b, c) has order p exactly when b . Phi(t) = 1 / gamma(t) for every rooted
4
+ tree t with at most p vertices (Butcher). This is algebra on the coefficients: it needs no test
5
+ problem, cannot be fooled by a lucky cancellation or a superconvergent test equation, and works the
6
+ same for explicit and implicit methods.
7
+
8
+ Lessons from testing other people's code: one package's `order` labels were wrong for 3 of its
9
+ tables (one claimed 4 where the table satisfies only the order-3 conditions), and another's embedded
10
+ weights for Tsitouras 5(4) did not even sum to 1. Order studies on test equations found both, but
11
+ were slower to adjudicate than this check.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from functools import lru_cache
17
+
18
+ import numpy as np
19
+
20
+ from .checks import FAIL, PASS, Result
21
+
22
+
23
+ @lru_cache(maxsize=None)
24
+ def trees(order: int) -> tuple:
25
+ """All rooted trees with `order` vertices, each a sorted tuple of child subtrees."""
26
+ if order == 1:
27
+ return ((),)
28
+ out = set()
29
+
30
+ def forests(n, max_child):
31
+ # multisets of trees with n vertices in total, children in non-increasing canonical order
32
+ if n == 0:
33
+ yield ()
34
+ return
35
+ for k in range(n, 0, -1):
36
+ for t in trees(k):
37
+ if max_child is not None and (k, t) > max_child:
38
+ continue
39
+ for rest in forests(n - k, (k, t)):
40
+ yield ((k, t),) + rest
41
+
42
+ for forest in forests(order - 1, None):
43
+ out.add(tuple(sorted((t for _, t in forest), key=repr)))
44
+ return tuple(sorted(out, key=repr))
45
+
46
+
47
+ def _size(t) -> int:
48
+ return 1 + sum(_size(s) for s in t)
49
+
50
+
51
+ def _gamma(t) -> int:
52
+ g = _size(t)
53
+ for s in t:
54
+ g *= _gamma(s)
55
+ return g
56
+
57
+
58
+ def _phi(t, A, e):
59
+ v = e.copy()
60
+ for s in t:
61
+ v = v * (A @ _phi(s, A, e))
62
+ return v
63
+
64
+
65
+ def order_residuals(A, b, max_order: int = 6) -> dict:
66
+ """{order: largest |b . Phi(t) - 1/gamma(t)| over the trees of that order}."""
67
+ A = np.asarray(A, dtype=float)
68
+ b = np.asarray(b, dtype=float)
69
+ e = np.ones(len(b))
70
+ return {p: max(abs(float(b @ _phi(t, A, e)) - 1.0 / _gamma(t)) for t in trees(p)) for p in range(1, max_order + 1)}
71
+
72
+
73
+ def tableau_order(A, b, *, max_order: int = 6, tol: float = 1e-8) -> int:
74
+ """Highest p <= max_order such that every condition up to order p holds within tol."""
75
+ p = 0
76
+ for q, r in order_residuals(A, b, max_order).items():
77
+ if r > tol:
78
+ break
79
+ p = q
80
+ return p
81
+
82
+
83
+ def check_tableau(A, b, c=None, *, claimed: int, label: str = "", tol: float = 1e-8, max_order: int | None = None) -> Result:
84
+ """PASS when the table has at least the claimed order; FAIL when it has less.
85
+
86
+ The default tolerance 1e-8 accepts coefficients typed with 9 or 10 digits (a real package had
87
+ residuals of 2e-10 for that reason), while a missing order shows residuals of 1e-4 or more.
88
+ The message gives the residuals, so a table whose conditions hold only to about 1e-10 is visible, and a claim that is lower than the true
89
+ order is noted rather than failed. If `c` is given, the row sums of A are compared with it.
90
+ """
91
+ top = max_order or claimed + 1
92
+ res = order_residuals(A, b, top)
93
+ p = tableau_order(A, b, max_order=top, tol=tol)
94
+ shown = ", ".join(f"{q}: {r:.1e}" for q, r in res.items())
95
+ prefix = f"{label}: " if label else ""
96
+ notes = []
97
+ if c is not None:
98
+ rs = float(np.max(np.abs(np.asarray(A, float).sum(axis=1) - np.asarray(c, float))))
99
+ if rs > tol:
100
+ notes.append(f"row sums of A differ from c by {rs:.1e}")
101
+ worst_kept = max((r for q, r in res.items() if q <= max(p, 1)), default=0.0)
102
+ if p >= 1 and worst_kept > 1e-13:
103
+ notes.append(f"conditions hold only to {worst_kept:.0e} (coefficients with limited digits?)")
104
+ tail = ("; " + "; ".join(notes)) if notes else ""
105
+ if p < claimed:
106
+ return Result(FAIL, f"{prefix}claims order {claimed} but satisfies the order conditions only up to {p} "
107
+ f"(largest residual per order: {shown}){tail}")
108
+ if p > claimed:
109
+ return Result(PASS, f"{prefix}claims order {claimed}; the conditions hold up to at least {p}, so the claim is "
110
+ f"conservative ({shown}){tail}")
111
+ return Result(PASS, f"{prefix}order {claimed} confirmed by the order conditions ({shown}){tail}")
@@ -0,0 +1,121 @@
1
+ """Find numeric literals in source code that are close to, but not equal to, a physical constant.
2
+
3
+ Lessons from testing other people's code: a gravitational constant in au^3 / (Msun yr^2) of
4
+ 39.4769 (Gaussian, 365.25-day year) next to an orbital speed of 2 pi (which assumes 4 pi^2 = 39.4784),
5
+ g = 9 in a projectile game, pi typed as 3.14. None of these is necessarily a bug, but each is worth
6
+ one look, and an adapter's reference must use the code's value, not the textbook one.
7
+
8
+ `scan(path)` returns notes, never failures: the code may be right to round.
9
+ Several CODATA editions are accepted. When writing a reference, use the edition the code says it uses:
10
+ one adapter failed at 1.35e-9 by taking the 2022 proton mass where the code stated 2018.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import math
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ # name: (accepted values, unit, comment)
21
+ KNOWN = {
22
+ "pi": ((math.pi,), "", "use math.pi or numpy.pi"),
23
+ "e (Euler)": ((math.e,), "", "use math.e or numpy.e"),
24
+ "g": ((9.80665, 9.81, 9.8), "m/s^2", "standard gravity 9.80665"),
25
+ "G (SI)": ((6.67430e-11, 6.674e-11, 6.67e-11), "m^3 kg^-1 s^-2", "CODATA 2018: 6.67430e-11"),
26
+ "G M_sun / 4 pi^2 (au, yr)": ((4 * math.pi**2,), "au^3 Msun^-1 yr^-2",
27
+ "4 pi^2 is exact for a 1 au, 1 yr circular orbit; 39.4769 is the Gaussian value for a 365.25-day year"),
28
+ "c": ((299792458.0, 3e8, 2.998e8), "m/s", "exact: 299792458"),
29
+ "h (Planck)": ((6.62607015e-34,), "J s", "exact: 6.62607015e-34"),
30
+ "hbar": ((1.054571817e-34,), "J s", "1.054571817e-34"),
31
+ "k_B": ((1.380649e-23,), "J/K", "exact: 1.380649e-23"),
32
+ "e (charge)": ((1.602176634e-19,), "C", "exact: 1.602176634e-19"),
33
+ "m_e": ((9.1093837015e-31, 9.1093837139e-31), "kg", "CODATA 2018: 9.1093837015e-31, 2022: 9.1093837139e-31"),
34
+ "m_p": ((1.67262192369e-27, 1.67262192595e-27), "kg", "CODATA 2018: 1.67262192369e-27, 2022: 1.67262192595e-27"),
35
+ "epsilon_0": ((8.8541878128e-12,), "F/m", "8.8541878128e-12"),
36
+ "T_c (2D Ising)": ((2 / math.log(1 + math.sqrt(2)),), "J/k_B", "2 / ln(1 + sqrt 2) = 2.269185314"),
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class Note:
42
+ line: int
43
+ literal: str
44
+ constant: str
45
+ nearest: float
46
+ relative: float
47
+ comment: str
48
+
49
+ def __str__(self):
50
+ return (f"line {self.line}: {self.literal} is {self.relative:+.2g} relative to {self.constant} "
51
+ f"({self.nearest:.10g}); {self.comment}")
52
+
53
+
54
+ def _digits(x: float) -> int:
55
+ """Significant digits of a literal as written (3.14 -> 3)."""
56
+ s = repr(x).lower().split("e")[0].replace("-", "").replace(".", "").lstrip("0")
57
+ return max(len(s.rstrip("0")), 1)
58
+
59
+
60
+ def check_value(value: float, *, max_rel: float = 0.1):
61
+ """(constant, nearest accepted value, relative difference) for a near miss, else None.
62
+
63
+ A value equal to an accepted value, or equal to it to the precision written with
64
+ at least 4 digits, is not a near miss: 3.1416 for pi and 9.81 for g are fine;
65
+ 3.14 and 9 are noted.
66
+ """
67
+ if value == 0 or not math.isfinite(value):
68
+ return None
69
+ best = None
70
+ for name, (accepted, _unit, comment) in KNOWN.items():
71
+ for ref in accepted:
72
+ rel = value / ref - 1
73
+ if abs(rel) > max_rel:
74
+ continue
75
+ if value == ref:
76
+ return None
77
+ written = 0.5 * 10 ** (1 - _digits(value)) # half a unit in the last written digit, relative
78
+ if _digits(value) >= 4 and abs(rel) <= written * 1.0000001:
79
+ return None # matches to the precision written, with at least 4 digits
80
+ if best is None or abs(rel) < abs(best[2]):
81
+ best = (name, ref, rel, comment)
82
+ return best
83
+
84
+
85
+ # names under which a short literal (an integer, or fewer than 3 digits) is still taken to mean the constant
86
+ NAMES = {"g": {"g", "grav", "gravity", "g_acc", "gacc"}, "G (SI)": {"G"}, "pi": {"pi", "PI"}, "c": {"c"}}
87
+
88
+
89
+ def _assigned_names(tree):
90
+ """id(value node) -> names it is assigned to, for simple `name = literal` statements."""
91
+ out = {}
92
+ for node in ast.walk(tree):
93
+ if isinstance(node, ast.Assign):
94
+ out[id(node.value)] = {t.id for t in node.targets if isinstance(t, ast.Name)}
95
+ elif isinstance(node, ast.AnnAssign) and node.value is not None and isinstance(node.target, ast.Name):
96
+ out[id(node.value)] = {node.target.id}
97
+ return out
98
+
99
+
100
+ def scan(path, *, max_rel: float = 0.1) -> list[Note]:
101
+ """Near-miss constants among the numeric literals of a Python file.
102
+
103
+ Literals with 3 or more significant digits are compared with every constant.
104
+ Integers and shorter literals (9, 3.1, 10) are so common that they are noted
105
+ only when assigned to a name that suggests the constant (g = 9).
106
+ """
107
+ path = Path(path)
108
+ tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
109
+ names = _assigned_names(tree)
110
+ notes = []
111
+ for node in ast.walk(tree):
112
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
113
+ v = float(node.value)
114
+ hit = check_value(abs(v), max_rel=max_rel)
115
+ if hit and (isinstance(node.value, int) or _digits(v) < 3):
116
+ if not names.get(id(node), set()) & NAMES.get(hit[0], set()):
117
+ hit = None
118
+ if hit:
119
+ name, ref, rel, comment = hit
120
+ notes.append(Note(node.lineno, ast.unparse(node), name, ref, rel, comment))
121
+ return sorted(notes, key=lambda n: n.line)
@@ -0,0 +1,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