physverify 0.5.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {physverify-0.5.0/src/physverify.egg-info → physverify-0.5.1}/PKG-INFO +5 -3
  2. {physverify-0.5.0 → physverify-0.5.1}/README.md +4 -2
  3. {physverify-0.5.0 → physverify-0.5.1}/pyproject.toml +1 -1
  4. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/__init__.py +1 -1
  5. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/agent.py +8 -2
  6. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/constants.py +3 -1
  7. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/lint.py +18 -3
  8. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/scan.py +52 -4
  9. {physverify-0.5.0 → physverify-0.5.1/src/physverify.egg-info}/PKG-INFO +5 -3
  10. {physverify-0.5.0 → physverify-0.5.1}/tests/test_agent.py +16 -0
  11. {physverify-0.5.0 → physverify-0.5.1}/tests/test_scan.py +71 -0
  12. {physverify-0.5.0 → physverify-0.5.1}/LICENSE +0 -0
  13. {physverify-0.5.0 → physverify-0.5.1}/setup.cfg +0 -0
  14. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/__main__.py +0 -0
  15. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/butcher.py +0 -0
  16. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/checks.py +0 -0
  17. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/cli.py +0 -0
  18. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/demo.py +0 -0
  19. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/identities.py +0 -0
  20. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/loaders.py +0 -0
  21. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/mms.py +0 -0
  22. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/order.py +0 -0
  23. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/pytest_plugin.py +0 -0
  24. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/references.py +0 -0
  25. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/report.py +0 -0
  26. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/scaffold.py +0 -0
  27. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/smoke.py +0 -0
  28. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/steppers.py +0 -0
  29. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/__init__.py +0 -0
  30. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/calibrate.py +0 -0
  31. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/checks.py +0 -0
  32. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/exact.py +0 -0
  33. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/ising.py +0 -0
  34. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/reference.py +0 -0
  35. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/sampler.py +0 -0
  36. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/stochastic/stats.py +0 -0
  37. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/studies.py +0 -0
  38. {physverify-0.5.0 → physverify-0.5.1}/src/physverify/symbolic.py +0 -0
  39. {physverify-0.5.0 → physverify-0.5.1}/src/physverify.egg-info/SOURCES.txt +0 -0
  40. {physverify-0.5.0 → physverify-0.5.1}/src/physverify.egg-info/dependency_links.txt +0 -0
  41. {physverify-0.5.0 → physverify-0.5.1}/src/physverify.egg-info/entry_points.txt +0 -0
  42. {physverify-0.5.0 → physverify-0.5.1}/src/physverify.egg-info/requires.txt +0 -0
  43. {physverify-0.5.0 → physverify-0.5.1}/src/physverify.egg-info/top_level.txt +0 -0
  44. {physverify-0.5.0 → physverify-0.5.1}/tests/test_awkward_input.py +0 -0
  45. {physverify-0.5.0 → physverify-0.5.1}/tests/test_batch7_lessons.py +0 -0
  46. {physverify-0.5.0 → physverify-0.5.1}/tests/test_batch8_lessons.py +0 -0
  47. {physverify-0.5.0 → physverify-0.5.1}/tests/test_butcher_properties.py +0 -0
  48. {physverify-0.5.0 → physverify-0.5.1}/tests/test_cli.py +0 -0
  49. {physverify-0.5.0 → physverify-0.5.1}/tests/test_corpus_lessons.py +0 -0
  50. {physverify-0.5.0 → physverify-0.5.1}/tests/test_docs_examples.py +0 -0
  51. {physverify-0.5.0 → physverify-0.5.1}/tests/test_figures.py +0 -0
  52. {physverify-0.5.0 → physverify-0.5.1}/tests/test_ising.py +0 -0
  53. {physverify-0.5.0 → physverify-0.5.1}/tests/test_method_bugs.py +0 -0
  54. {physverify-0.5.0 → physverify-0.5.1}/tests/test_mms.py +0 -0
  55. {physverify-0.5.0 → physverify-0.5.1}/tests/test_new_problems.py +0 -0
  56. {physverify-0.5.0 → physverify-0.5.1}/tests/test_order.py +0 -0
  57. {physverify-0.5.0 → physverify-0.5.1}/tests/test_properties.py +0 -0
  58. {physverify-0.5.0 → physverify-0.5.1}/tests/test_real_projects.py +0 -0
  59. {physverify-0.5.0 → physverify-0.5.1}/tests/test_reference_problems.py +0 -0
  60. {physverify-0.5.0 → physverify-0.5.1}/tests/test_references.py +0 -0
  61. {physverify-0.5.0 → physverify-0.5.1}/tests/test_scaffold.py +0 -0
  62. {physverify-0.5.0 → physverify-0.5.1}/tests/test_seeded_bugs.py +0 -0
  63. {physverify-0.5.0 → physverify-0.5.1}/tests/test_studies.py +0 -0
  64. {physverify-0.5.0 → physverify-0.5.1}/tests/test_v040_helpers.py +0 -0
  65. {physverify-0.5.0 → physverify-0.5.1}/tests/test_versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: physverify
3
- Version: 0.5.0
3
+ Version: 0.5.1
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
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
 
46
46
  **Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
47
47
 
48
- physverify is actively evolving (v0.5.0), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
48
+ physverify is actively evolving (v0.5.1), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
49
49
 
50
50
  ![The check loop: your solver and an independent description of the physics go in; physverify runs the solver at several resolutions, measures the error and reports a verdict with a reason](https://raw.githubusercontent.com/rcgiri-physics/physverify/main/docs/images/check-loop.svg)
51
51
 
@@ -72,7 +72,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
72
72
  A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
73
73
 
74
74
  ```yaml
75
- - uses: rcgiri-physics/physverify@v0.5.0
75
+ - uses: rcgiri-physics/physverify@v0.5.1
76
76
  with:
77
77
  specs: tests/verify_*.py
78
78
  ```
@@ -90,3 +90,5 @@ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a sour
90
90
  ## Contributing, citing, licence
91
91
 
92
92
  Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md), the [code of conduct](https://github.com/rcgiri-physics/physverify/blob/main/CODE_OF_CONDUCT.md), [SUPPORT.md](https://github.com/rcgiri-physics/physverify/blob/main/SUPPORT.md) for help, [SECURITY.md](https://github.com/rcgiri-physics/physverify/blob/main/SECURITY.md) on running untrusted code, and [AGENTS.md](https://github.com/rcgiri-physics/physverify/blob/main/AGENTS.md) for AI assistants. To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). MIT licence, copyright (c) 2026 Ram Chandra Giri.
93
+
94
+ <!-- mcp-name: io.github.rcgiri-physics/physverify -->
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
6
6
 
7
- physverify is actively evolving (v0.5.0), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
7
+ physverify is actively evolving (v0.5.1), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
8
8
 
9
9
  ![The check loop: your solver and an independent description of the physics go in; physverify runs the solver at several resolutions, measures the error and reports a verdict with a reason](https://raw.githubusercontent.com/rcgiri-physics/physverify/main/docs/images/check-loop.svg)
10
10
 
@@ -31,7 +31,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
31
31
  A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
32
32
 
33
33
  ```yaml
34
- - uses: rcgiri-physics/physverify@v0.5.0
34
+ - uses: rcgiri-physics/physverify@v0.5.1
35
35
  with:
36
36
  specs: tests/verify_*.py
37
37
  ```
@@ -49,3 +49,5 @@ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a sour
49
49
  ## Contributing, citing, licence
50
50
 
51
51
  Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md), the [code of conduct](https://github.com/rcgiri-physics/physverify/blob/main/CODE_OF_CONDUCT.md), [SUPPORT.md](https://github.com/rcgiri-physics/physverify/blob/main/SUPPORT.md) for help, [SECURITY.md](https://github.com/rcgiri-physics/physverify/blob/main/SECURITY.md) on running untrusted code, and [AGENTS.md](https://github.com/rcgiri-physics/physverify/blob/main/AGENTS.md) for AI assistants. To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). MIT licence, copyright (c) 2026 Ram Chandra Giri.
52
+
53
+ <!-- mcp-name: io.github.rcgiri-physics/physverify -->
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "physverify"
7
- version = "0.5.0"
7
+ version = "0.5.1"
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.5.0"
9
+ __version__ = "0.5.1"
10
10
 
11
11
  __all__ = [
12
12
  "Check",
@@ -55,7 +55,9 @@ def find_specs(root: Path) -> list[Path]:
55
55
  except Exception:
56
56
  config = {}
57
57
  if config.get("specs"):
58
- return [(root / s).resolve() for s in config["specs"]]
58
+ # Entries that leave the root are dropped (GCVE-110-VULN-PYPI-2026-000391).
59
+ inside = root.resolve()
60
+ return [p for p in ((root / s).resolve() for s in config["specs"]) if p.is_relative_to(inside)]
59
61
  specs = []
60
62
  for path in sorted(root.rglob("verify_*.py")):
61
63
  parts = path.relative_to(root).parts[:-1]
@@ -111,8 +113,12 @@ def hook_main(stdin=None, stderr=None) -> int:
111
113
  edited = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
112
114
  if edited and not edited.endswith(".py"):
113
115
  return 0
114
- cwd = Path(event.get("cwd") or ".")
116
+ cwd = Path(event.get("cwd") or ".").resolve()
117
+ if edited and not Path(edited).resolve().is_relative_to(cwd):
118
+ return 0 # an edit outside the session folder must not run that folder's code (GCVE-110-VULN-PYPI-2026-000391)
115
119
  root = find_project_root(Path(edited).parent if edited else cwd)
120
+ if not root.is_relative_to(cwd):
121
+ root = cwd # never climb above the session folder
116
122
  specs = find_specs(root)
117
123
  if not specs:
118
124
  return 0
@@ -32,11 +32,13 @@ KNOWN = {
32
32
  "e (charge)": ((1.602176634e-19,), "C", "exact: 1.602176634e-19"),
33
33
  "m_e": ((9.1093837015e-31, 9.1093837139e-31), "kg", "CODATA 2018: 9.1093837015e-31, 2022: 9.1093837139e-31"),
34
34
  "m_p": ((1.67262192369e-27, 1.67262192595e-27), "kg", "CODATA 2018: 1.67262192369e-27, 2022: 1.67262192595e-27"),
35
+ "m_n": ((1.67492749804e-27, 1.67492750056e-27), "kg", "CODATA 2018: 1.67492749804e-27, 2022: 1.67492750056e-27"),
36
+ "atomic mass unit": ((1.66053906660e-27, 1.66053906892e-27), "kg", "CODATA 2018: 1.66053906660e-27, 2022: 1.66053906892e-27"),
35
37
  "epsilon_0": ((8.8541878128e-12,), "F/m", "8.8541878128e-12"),
36
38
  "T_c (2D Ising)": ((2 / math.log(1 + math.sqrt(2)),), "J/k_B", "2 / ln(1 + sqrt 2) = 2.269185314"),
37
39
  # exact by definition (SI 2019, IAU 2012, international units)
38
40
  "N_A": ((6.02214076e23,), "1/mol", "exact: 6.02214076e23"),
39
- "R (gas constant)": ((8.314462618,), "J/(mol K)", "N_A k_B = 8.314462618..."),
41
+ "R (gas constant)": ((8.31446261815324,), "J/(mol K)", "exact: N_A k_B = 8.31446261815324"),
40
42
  "sigma (Stefan-Boltzmann)": ((5.670374419e-8,), "W m^-2 K^-4", "5.670374419e-8"),
41
43
  "astronomical unit": ((149597870700.0, 149597870.7, 1.495978707e13), "m, km or cm", "exact: 149597870700 m (IAU 2012)"),
42
44
  "light year": ((9460730472580800.0,), "m", "exact: 9460730472580800 m (Julian year)"),
@@ -129,6 +129,10 @@ def _annotation_nodes(tree) -> set[int]:
129
129
  return {id(m) for r in roots for m in ast.walk(r)}
130
130
 
131
131
 
132
+ # a file with this many undefined names, none of them a usual module alias, is treated as getting them at run time
133
+ _INJECTED_MIN = 6
134
+ _COMMON_ALIASES = {"np", "plt", "pd", "sp", "scipy", "numpy", "math", "os", "sys", "re", "json", "time", "random",
135
+ "torch", "jnp", "jax", "tf", "mpl", "sns", "nx", "integrate", "interpolate", "optimize", "linalg"}
132
136
  _NOQA = re.compile(r"#\s*noqa(?::[^#]*\bF821\b|\s*$|\s*#)", re.I)
133
137
 
134
138
 
@@ -155,6 +159,14 @@ def scan_names(path: Path, tree, rel: str, source: str = "") -> list[Finding]:
155
159
  continue # annotations are not evaluated with `from __future__ import annotations` (or from Python 3.14)
156
160
  unknown.setdefault(n.id, []).append(n.lineno)
157
161
  out = []
162
+ if len(unknown) >= _INJECTED_MIN and not unknown.keys() & _COMMON_ALIASES:
163
+ # many names and none of them a forgotten import: the file most likely gets them at run time
164
+ # (a host program such as SpaceClaim, or a library that defines names in the caller's namespace)
165
+ first = min(lines[0] for lines in unknown.values())
166
+ shown = ", ".join(sorted(unknown)[:8]) + (", ..." if len(unknown) > 8 else "")
167
+ return [Finding("names", NOTE, rel, first,
168
+ f"{len(unknown)} names are used but never defined or imported ({shown}); so many usually means they"
169
+ " are provided at run time by a host program or a library, so they are not reported one by one")]
158
170
  for name, lines in sorted(unknown.items(), key=lambda kv: kv[1][0]):
159
171
  close = [k for k in known if k != name and len(k) > 2 and _one_edit(name, k)]
160
172
  hint = f" (did you mean {close[0]}?)" if close else ""
@@ -225,7 +237,8 @@ _SCIPY = {
225
237
  "cumtrapz": "scipy.integrate.cumulative_trapezoid", "romb": None},
226
238
  "scipy.misc": {"*": "scipy.datasets, scipy.special or numpy (scipy.misc was removed)"},
227
239
  }
228
- _PY_REMOVED = {"imp": "importlib (removed in Python 3.12)", "distutils": "setuptools or sysconfig (removed in Python 3.12)",
240
+ _SHIMMED = {"distutils"} # still importable wherever setuptools is installed, so only a NOTE
241
+ _PY_REMOVED = {"imp": "importlib (removed in Python 3.12)", "distutils": "setuptools or sysconfig (removed from the standard library in Python 3.12; setuptools still provides it)",
229
242
  "asynchat": "asyncio (removed in Python 3.12)", "asyncore": "asyncio (removed in Python 3.12)",
230
243
  "smtpd": "aiosmtpd (removed in Python 3.12)"}
231
244
 
@@ -281,12 +294,14 @@ def scan_api(path: Path, tree, rel: str) -> list[Finding]:
281
294
  out.append(Finding("api", WARN, rel, n.lineno, f"{n.module}.{a.name} has been removed from SciPy"
282
295
  + (f"; use {new}" if new else "")))
283
296
  if n.module.split(".")[0] in _PY_REMOVED:
284
- out.append(Finding("api", WARN, rel, n.lineno, f"{n.module}: use {_PY_REMOVED[n.module.split('.')[0]]}"))
297
+ top = n.module.split(".")[0]
298
+ out.append(Finding("api", NOTE if top in _SHIMMED else WARN, rel, n.lineno, f"{n.module}: use {_PY_REMOVED[top]}"))
285
299
  if isinstance(n, ast.Import):
286
300
  for a in n.names:
287
301
  top = a.name.split(".")[0]
288
302
  if top in _PY_REMOVED:
289
- out.append(Finding("api", WARN, rel, n.lineno, f"import {a.name}: use {_PY_REMOVED[top]}"))
303
+ out.append(Finding("api", NOTE if top in _SHIMMED else WARN, rel, n.lineno,
304
+ f"import {a.name}: use {_PY_REMOVED[top]}"))
290
305
  if a.name == "scipy.misc":
291
306
  out.append(Finding("api", WARN, rel, n.lineno, "scipy.misc has been removed from SciPy"))
292
307
  if isinstance(n, ast.Attribute) and n.attr in ("simps", "cumtrapz") and isinstance(n.value, ast.Attribute) \
@@ -26,7 +26,7 @@ import ast
26
26
  import math
27
27
  import re
28
28
  import warnings
29
- from dataclasses import asdict, dataclass
29
+ from dataclasses import asdict, dataclass, field
30
30
  from fractions import Fraction
31
31
  from pathlib import Path
32
32
 
@@ -42,6 +42,7 @@ class Finding:
42
42
  path: str
43
43
  line: int
44
44
  message: str
45
+ table: dict | None = field(default=None, repr=False) # the Runge-Kutta table a "tables" finding was made from
45
46
 
46
47
  def __str__(self):
47
48
  return f"{self.level} {self.stage:9s} {self.path}:{self.line}: {self.message}"
@@ -364,7 +365,14 @@ def _check_table(lits, name, doc, stem, stated, rel, *, kind="table", stated_emb
364
365
  msgs.append(f"embedded weights {en} have order {pe}, but order {stated_embedded} is stated")
365
366
  else:
366
367
  msgs.append(f"embedded weights {en} have order {pe}")
367
- found.append(Finding("tables", level, rel, line, f"{where}: " + "; ".join(msgs)))
368
+ table = {"name": name, "kind": kind, "A": A, "b": b, "c": lits[cn][0] if cn is not None else None,
369
+ "claimed": claimed if claimed is not None and claimed <= 6 else None, "claim_source": why,
370
+ "stated_embedded": stated_embedded,
371
+ "embedded": {en: e for en, (e, _l) in lits.items() if _BHAT_NAMES.match(en) and _vector(e, s) and en != bn},
372
+ "error_weights": {en: e for en, (e, _l) in lits.items()
373
+ if (_E_NAMES.match(en) or en == "<e>") and _vector(e, s) and en != bn},
374
+ "order": p}
375
+ found.append(Finding("tables", level, rel, line, f"{where}: " + "; ".join(msgs), table))
368
376
  return found
369
377
 
370
378
 
@@ -745,7 +753,11 @@ def scan_code_tables(path: Path, tree, rel: str) -> list[Finding]:
745
753
  msgs.append(f"but '{why}' suggests order {claimed} (largest residual {worst:.1e})")
746
754
  else:
747
755
  msgs.append(f"consistent with '{why}'")
748
- found.append(Finding("tables", level, rel, line, f"{fn.name}: " + "; ".join(msgs)))
756
+ table = {"name": fn.name, "kind": "stages written as code", "A": A, "b": b, "c": c,
757
+ "claimed": claimed if claimed is not None and claimed <= 6 else None, "claim_source": why,
758
+ "stated_embedded": None, "embedded": {}, "error_weights": {}, "order": p,
759
+ "docstring_table": {"A": drawn[0], "b": drawn[1]} if drawn is not None and len(drawn[1]) == s else None}
760
+ found.append(Finding("tables", level, rel, line, f"{fn.name}: " + "; ".join(msgs), table))
749
761
  return found
750
762
 
751
763
 
@@ -850,6 +862,7 @@ def _own_grids_and_divs(scope):
850
862
  nodes = list(_own_nodes(scope))
851
863
  # a division used as the step of a slice (np.mgrid[a:b:(b-a)/n], arange-style) builds its own grid
852
864
  steps = {id(n.step) for n in nodes if isinstance(n, ast.Slice) and n.step is not None}
865
+ periodic = [] # linspace(a, b, M, endpoint=False): its spacing really is (b - a) / M
853
866
  for node in nodes:
854
867
  if id(node) in steps:
855
868
  continue
@@ -858,12 +871,17 @@ def _own_grids_and_divs(scope):
858
871
  if fn == "linspace" and len(node.args) >= 3:
859
872
  if any(k.arg == "endpoint" and isinstance(k.value, ast.Constant) and k.value.value is False
860
873
  for k in node.keywords):
874
+ periodic.append(node.args[2])
861
875
  continue
862
876
  grids.append((node.args[0], node.args[1], node.args[2], node.lineno))
863
877
  if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
864
878
  den_names = _names(node.right)
865
879
  if den_names: # x / 2 cannot be a grid spacing with a named number of points
866
880
  divs.append((node, node.lineno, den_names))
881
+ if periodic:
882
+ # a spacing L / M next to its own periodic grid of M points is right; do not pair it with grids elsewhere
883
+ divs = [d for d in divs if not any(_src(d[0].right) == _src(m) or _src(d[0].right) == f"({_src(m)})"
884
+ for m in periodic)]
867
885
  return grids, divs
868
886
 
869
887
 
@@ -913,7 +931,9 @@ _PAIR = re.compile(r"^(?P<pre>.*?)(?P<x>[a-z0-9]+)(?P<sep>_to_|2)(?P<y>[a-z0-9]+
913
931
 
914
932
 
915
933
  def scan_pairs(path: Path, tree, rel: str) -> list[Finding]:
916
- funcs = {n.name: n.lineno for n in all_nodes(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
934
+ # test_x_to_y / test_y_to_x test an inverse pair; they are not converters to run
935
+ funcs = {n.name: n.lineno for n in all_nodes(tree)
936
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and not n.name.startswith("test")}
917
937
  found, seen = [], set()
918
938
  for fname, line in funcs.items():
919
939
  m = _PAIR.match(fname)
@@ -934,6 +954,11 @@ def scan_pairs(path: Path, tree, rel: str) -> list[Finding]:
934
954
  # them only when they are close; dimensional constants (6.6e-11, 3e8) rarely match by chance.
935
955
  _PURE = {"pi", "e (Euler)", "T_c (2D Ising)", "G M_sun / 4 pi^2 (au, yr)"}
936
956
  _PURE_REL = 2e-3
957
+ # Constants with units: an old edition or a rounded value is within 0.1%, while values 2-10% away were almost all
958
+ # other quantities in the corpus rescan (test values, the atomic mass unit next to the proton mass).
959
+ _DIM_REL = 1e-2
960
+ _TABLE_MIN = 8
961
+ _MANY_CONSTANTS = 20
937
962
  # Units and constants fixed by definition: a slip or an old value sits within 0.1%, so only that close is noted.
938
963
  _DEFINED = {"N_A", "R (gas constant)", "sigma (Stefan-Boltzmann)", "astronomical unit", "light year", "parsec",
939
964
  "standard atmosphere", "torr", "nautical mile", "h (CGS)", "k_B (CGS)", "c (CGS)", "e (esu)", "m_e (g)",
@@ -946,10 +971,15 @@ def scan_constants(path: Path, tree, rel: str) -> list[Finding]:
946
971
  from . import constants
947
972
 
948
973
  names = constants._assigned_names(tree)
974
+ # numbers in long literal tables are data (measurements, reference values), not constants typed by hand
975
+ tabulated = {id(e) for n in all_nodes(tree) if isinstance(n, (ast.List, ast.Tuple)) and len(n.elts) >= _TABLE_MIN
976
+ for e in n.elts}
949
977
  hits = {}
950
978
  for node in all_nodes(tree):
951
979
  if not (isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool)):
952
980
  continue
981
+ if id(node) in tabulated:
982
+ continue
953
983
  try:
954
984
  v = float(node.value)
955
985
  except OverflowError:
@@ -965,9 +995,16 @@ def scan_constants(path: Path, tree, rel: str) -> list[Finding]:
965
995
  continue
966
996
  elif name in _PURE and abs(r) > _PURE_REL:
967
997
  continue
998
+ elif name not in _PURE and abs(r) > _DIM_REL:
999
+ continue # 1.44e-19 is a test value, not a mistyped elementary charge
968
1000
  if name in _DEFINED and abs(r) > (_DEFINED_REL / 10 if isinstance(node.value, int) else _DEFINED_REL):
969
1001
  continue # a definition typed wrong or taken from an old edition is close; 14666785333344 is not an au
970
1002
  hits.setdefault((name, ast.unparse(node)), []).append((node.lineno, ref, r, comment))
1003
+ if len(hits) > _MANY_CONSTANTS:
1004
+ first = min(w[0][0] for w in hits.values())
1005
+ return [Finding("constants", NOTE, rel, first,
1006
+ f"{len(hits)} different numbers in this file are close to physical constants; so many usually means"
1007
+ " the file holds data (fits, tables, generated code), so they are not listed one by one")]
971
1008
  out = []
972
1009
  for (name, literal), where in sorted(hits.items(), key=lambda kv: kv[1][0][0]):
973
1010
  line, ref, r, comment = where[0]
@@ -1032,6 +1069,17 @@ def scan(path, *, stages=STAGES) -> tuple[list[Finding], list[str]]:
1032
1069
  return findings, unparsed
1033
1070
 
1034
1071
 
1072
+ def extract_tables(path) -> list[dict]:
1073
+ """Every Runge-Kutta table the scan finds under `path`, as dicts with A, b, c (or None), the claimed order and
1074
+ where the claim comes from, embedded and error weights, and the order the coefficients have. Runs nothing.
1075
+
1076
+ For checks the scan does not make itself, pass them on, e.g.
1077
+ `physverify.butcher.check_tableau(t["A"], t["b"], t["c"], claimed=t["claimed"])`.
1078
+ """
1079
+ findings, _unparsed = scan(path, stages=("tables",))
1080
+ return [dict(f.table, path=f.path, line=f.line, level=f.level) for f in findings if f.table is not None]
1081
+
1082
+
1035
1083
  def to_dict(findings, unparsed) -> dict:
1036
1084
  return {"findings": [asdict(f) for f in findings], "unparsed": unparsed,
1037
1085
  "warn": sum(f.level == WARN for f in findings), "note": sum(f.level == NOTE for f in findings)}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: physverify
3
- Version: 0.5.0
3
+ Version: 0.5.1
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
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
 
46
46
  **Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
47
47
 
48
- physverify is actively evolving (v0.5.0), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
48
+ physverify is actively evolving (v0.5.1), welcoming contributions and test cases from the computational physics community. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
49
49
 
50
50
  ![The check loop: your solver and an independent description of the physics go in; physverify runs the solver at several resolutions, measures the error and reports a verdict with a reason](https://raw.githubusercontent.com/rcgiri-physics/physverify/main/docs/images/check-loop.svg)
51
51
 
@@ -72,7 +72,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
72
72
  A failed check fails the job, like a broken unit test ([CI guide](https://rcgiri-physics.github.io/physverify/ci/): options, pre-commit, pytest):
73
73
 
74
74
  ```yaml
75
- - uses: rcgiri-physics/physverify@v0.5.0
75
+ - uses: rcgiri-physics/physverify@v0.5.1
76
76
  with:
77
77
  specs: tests/verify_*.py
78
78
  ```
@@ -90,3 +90,5 @@ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a sour
90
90
  ## Contributing, citing, licence
91
91
 
92
92
  Help is welcome: a trial report on your own solver, seeded bugs written by someone other than the author, adapters, documentation. See [CONTRIBUTING.md](https://github.com/rcgiri-physics/physverify/blob/main/CONTRIBUTING.md), the [code of conduct](https://github.com/rcgiri-physics/physverify/blob/main/CODE_OF_CONDUCT.md), [SUPPORT.md](https://github.com/rcgiri-physics/physverify/blob/main/SUPPORT.md) for help, [SECURITY.md](https://github.com/rcgiri-physics/physverify/blob/main/SECURITY.md) on running untrusted code, and [AGENTS.md](https://github.com/rcgiri-physics/physverify/blob/main/AGENTS.md) for AI assistants. To cite, use [doi:10.5281/zenodo.23534512](https://doi.org/10.5281/zenodo.23534512) or [CITATION.cff](https://github.com/rcgiri-physics/physverify/blob/main/CITATION.cff). Changes: [CHANGELOG.md](https://github.com/rcgiri-physics/physverify/blob/main/CHANGELOG.md); plans: [ROADMAP.md](https://github.com/rcgiri-physics/physverify/blob/main/ROADMAP.md). MIT licence, copyright (c) 2026 Ram Chandra Giri.
93
+
94
+ <!-- mcp-name: io.github.rcgiri-physics/physverify -->
@@ -68,6 +68,22 @@ def test_hook_ignores_non_python_edits_and_bad_input(project):
68
68
  assert hook_main(io.StringIO("not json"), io.StringIO()) == 0
69
69
 
70
70
 
71
+ def test_hook_does_not_run_code_outside_the_session_folder(project, tmp_path_factory):
72
+ session = tmp_path_factory.mktemp("session")
73
+ seed_bug(project)
74
+ edit = io.StringIO(json.dumps({"cwd": str(session),
75
+ "tool_input": {"file_path": str(project / "damped_oscillator.py")}}))
76
+ assert hook_main(edit, io.StringIO()) == 0
77
+
78
+
79
+ def test_pyproject_specs_outside_the_root_are_dropped(project, tmp_path_factory):
80
+ outside = tmp_path_factory.mktemp("outside") / "verify_evil.py"
81
+ outside.write_text("raise SystemExit('must not be imported')\n")
82
+ (project / "pyproject.toml").write_text(
83
+ f"[tool.physverify]\nspecs = ['verify_damped_oscillator.py', {str(outside)!r}, '../verify_up.py']\n")
84
+ assert find_specs(project) == [(project / "verify_damped_oscillator.py").resolve()]
85
+
86
+
71
87
  def test_mcp_lists_and_runs_the_tool(project):
72
88
  init = handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})
73
89
  assert init["result"]["serverInfo"]["name"] == "physverify"
@@ -532,3 +532,74 @@ def test_environment_names_and_nan_tests_are_quiet(tmp_path):
532
532
  _write(tmp_path / "tests", "test_nan.py", "import math\nassert not (1.0 == math.nan)\n")
533
533
  f = scan(tmp_path)[0]
534
534
  assert not _by_stage(f, "names") and not _by_stage(f, "literals")
535
+
536
+
537
+ def test_extract_tables_returns_the_coefficients(tmp_path):
538
+ from physverify.butcher import check_tableau
539
+ from physverify.scan import extract_tables
540
+
541
+ _write(tmp_path, "rk.py", _RK4_CODE)
542
+ _write(tmp_path, "tab.py", '''
543
+ def ssprk3():
544
+ """order 3"""
545
+ A = [[0, 0, 0], [1, 0, 0], [0.25, 0.25, 0]]
546
+ b = [1/6, 1/6, 2/3]
547
+ c = [0, 1, 0.5]
548
+ return A, b, c
549
+ ''')
550
+ tables = {t["name"]: t for t in extract_tables(tmp_path)}
551
+ assert set(tables) == {"rk4", "ssprk3"}
552
+ assert tables["rk4"]["kind"] == "stages written as code" and tables["rk4"]["claimed"] == 4
553
+ t = tables["ssprk3"]
554
+ assert check_tableau(t["A"], t["b"], t["c"], claimed=t["claimed"]).status == "PASS"
555
+
556
+
557
+ def test_spacing_of_its_own_periodic_grid_is_not_paired_elsewhere(tmp_path):
558
+ _write(tmp_path, "spectral.py", """
559
+ import numpy as np
560
+ def evolve(x_min, x_max, n_points):
561
+ x = np.linspace(x_min, x_max, n_points, endpoint=False)
562
+ dx = (x_max - x_min) / n_points
563
+ return x, dx
564
+ """)
565
+ _write(tmp_path, "fd.py", """
566
+ import numpy as np
567
+ def states(x_min, x_max, n_points):
568
+ x = np.linspace(x_min, x_max, n_points)
569
+ return x, x[1] - x[0]
570
+ """)
571
+ assert not _by_stage(scan(tmp_path)[0], "grid")
572
+
573
+
574
+ def test_many_injected_names_become_one_note(tmp_path):
575
+ _write(tmp_path, "host.py", """
576
+ sel = NamedSelection.Create(FaceSelection.Create(GetRootPart().Faces), Selection.Empty())
577
+ opts = FixMissingFacesOptions()
578
+ DocumentSave.Execute(GetRootPart().Document.Path)
579
+ CloseWindow()
580
+ """)
581
+ _write(tmp_path, "forgot.py", "x = np.sin(1)\ny = plt.plot(x)\nz = sparse.eye(3)\nw = linalg.inv(z)\nv = a + b + c\n")
582
+ f = _by_stage(scan(tmp_path)[0], "names")
583
+ host = [x for x in f if x.path == "host.py"]
584
+ assert len(host) == 1 and host[0].level == NOTE and "run time" in host[0].message
585
+ assert len([x for x in f if x.path == "forgot.py" and x.level == WARN]) >= 5
586
+
587
+
588
+ def test_constants_in_data_files_and_tables_are_summarised(tmp_path):
589
+ _write(tmp_path, "rates.py", "\n".join(f"r{i} = rate({2.7175 + 1e-5 * i:.6f}, {3.1405 + 1e-5 * i:.6f})"
590
+ for i in range(30)) + "\n")
591
+ _write(tmp_path, "table.py", "y = [3.1, 3.12, 3.13, 3.14, 3.15, 2.27, 2.71, 2.72]\n")
592
+ _write(tmp_path, "masses.py", "amu = 1.66053906660e-27\nm_n = 1.67492749804e-27\ncharge = 1.44e-19\n")
593
+ f = _by_stage(scan(tmp_path)[0], "constants")
594
+ assert [(x.path, "close to physical constants" in x.message) for x in f] == [("rates.py", True)]
595
+
596
+
597
+ def test_test_functions_are_not_inverse_pairs(tmp_path):
598
+ _write(tmp_path, "t.py", """
599
+ def test_xyz_to_lab(): pass
600
+ def test_lab_to_xyz(): pass
601
+ def c2k(t): return t
602
+ def k2c(t): return t
603
+ """)
604
+ f = _by_stage(scan(tmp_path)[0], "pairs")
605
+ assert len(f) == 1 and "c2k" in f[0].message
File without changes
File without changes
File without changes
File without changes