physverify 0.5.1__tar.gz → 0.7.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.5.1/src/physverify.egg-info → physverify-0.7.0}/PKG-INFO +5 -4
- {physverify-0.5.1 → physverify-0.7.0}/README.md +3 -3
- {physverify-0.5.1 → physverify-0.7.0}/pyproject.toml +2 -2
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/__init__.py +1 -1
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/agent.py +28 -11
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/butcher.py +111 -3
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/cli.py +23 -6
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/constants.py +4 -1
- physverify-0.7.0/src/physverify/fluxes.py +58 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/identities.py +111 -9
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/lint.py +64 -8
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/order.py +28 -0
- physverify-0.7.0/src/physverify/probes.py +1063 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/references.py +20 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/report.py +1 -1
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/scan.py +90 -3
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/smoke.py +3 -2
- physverify-0.7.0/src/physverify/specscan.py +67 -0
- physverify-0.7.0/src/physverify/stencils.py +69 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/stochastic/stats.py +3 -1
- {physverify-0.5.1 → physverify-0.7.0/src/physverify.egg-info}/PKG-INFO +5 -4
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify.egg-info/SOURCES.txt +7 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify.egg-info/requires.txt +3 -0
- physverify-0.7.0/tests/test_awkward_input.py +151 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_batch8_lessons.py +18 -0
- physverify-0.7.0/tests/test_helpers_2026_10.py +146 -0
- physverify-0.7.0/tests/test_probes.py +314 -0
- physverify-0.7.0/tests/test_roadmap_0_7.py +83 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_scan.py +123 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_versions.py +5 -0
- physverify-0.5.1/tests/test_awkward_input.py +0 -74
- {physverify-0.5.1 → physverify-0.7.0}/LICENSE +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/setup.cfg +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/__main__.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/checks.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/demo.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/loaders.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/mms.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/pytest_plugin.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/scaffold.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/steppers.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/stochastic/__init__.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/stochastic/calibrate.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/stochastic/checks.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/stochastic/exact.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/stochastic/ising.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/stochastic/reference.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/stochastic/sampler.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/studies.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify/symbolic.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify.egg-info/dependency_links.txt +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify.egg-info/entry_points.txt +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/src/physverify.egg-info/top_level.txt +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_agent.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_batch7_lessons.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_butcher_properties.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_cli.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_corpus_lessons.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_docs_examples.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_figures.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_ising.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_method_bugs.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_mms.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_new_problems.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_order.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_properties.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_real_projects.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_reference_problems.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_references.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_scaffold.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_seeded_bugs.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_studies.py +0 -0
- {physverify-0.5.1 → physverify-0.7.0}/tests/test_v040_helpers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: physverify
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -27,6 +27,7 @@ Requires-Python: >=3.10
|
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
License-File: LICENSE
|
|
29
29
|
Requires-Dist: numpy>=1.24
|
|
30
|
+
Requires-Dist: tomli>=1.1; python_version < "3.11"
|
|
30
31
|
Provides-Extra: symbolic
|
|
31
32
|
Requires-Dist: sympy>=1.12; extra == "symbolic"
|
|
32
33
|
Provides-Extra: dev
|
|
@@ -45,7 +46,7 @@ Dynamic: license-file
|
|
|
45
46
|
|
|
46
47
|
**Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
|
|
47
48
|
|
|
48
|
-
physverify is actively evolving (v0.
|
|
49
|
+
physverify is actively evolving (v0.7.0), welcoming contributions and test cases from the computational physics community. Status: under active development and maintenance, tested on a growing corpus of public physics codes; releases come at milestones. Scope: numerical solutions of ODEs and PDEs (time integration, convergence, conservation), plus a few Monte Carlo checks. It needs only NumPy.
|
|
49
50
|
|
|
50
51
|

|
|
51
52
|
|
|
@@ -72,7 +73,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
|
|
|
72
73
|
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
74
|
|
|
74
75
|
```yaml
|
|
75
|
-
- uses: rcgiri-physics/physverify@v0.
|
|
76
|
+
- uses: rcgiri-physics/physverify@v0.7.0
|
|
76
77
|
with:
|
|
77
78
|
specs: tests/verify_*.py
|
|
78
79
|
```
|
|
@@ -89,6 +90,6 @@ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a sour
|
|
|
89
90
|
|
|
90
91
|
## Contributing, citing, licence
|
|
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
|
+
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). The methods are standard and other tools do parts of this, some better (NodePy for Runge-Kutta analysis); see [related work and credits](https://rcgiri-physics.github.io/physverify/related-work/). MIT licence, copyright (c) 2026 Ram Chandra Giri.
|
|
93
94
|
|
|
94
95
|
<!-- 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.
|
|
7
|
+
physverify is actively evolving (v0.7.0), welcoming contributions and test cases from the computational physics community. Status: under active development and maintenance, tested on a growing corpus of public physics codes; releases come at milestones. 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
|

|
|
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.
|
|
34
|
+
- uses: rcgiri-physics/physverify@v0.7.0
|
|
35
35
|
with:
|
|
36
36
|
specs: tests/verify_*.py
|
|
37
37
|
```
|
|
@@ -48,6 +48,6 @@ Works best for ODEs; PDEs only in 1D so far. Your code needs a way to add a sour
|
|
|
48
48
|
|
|
49
49
|
## Contributing, citing, licence
|
|
50
50
|
|
|
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.
|
|
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). The methods are standard and other tools do parts of this, some better (NodePy for Runge-Kutta analysis); see [related work and credits](https://rcgiri-physics.github.io/physverify/related-work/). MIT licence, copyright (c) 2026 Ram Chandra Giri.
|
|
52
52
|
|
|
53
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.
|
|
7
|
+
version = "0.7.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"
|
|
@@ -28,7 +28,7 @@ classifiers = [
|
|
|
28
28
|
"Topic :: Software Development :: Testing",
|
|
29
29
|
"Development Status :: 3 - Alpha",
|
|
30
30
|
]
|
|
31
|
-
dependencies = ["numpy>=1.24"]
|
|
31
|
+
dependencies = ["numpy>=1.24", "tomli>=1.1; python_version < '3.11'"]
|
|
32
32
|
|
|
33
33
|
[project.urls]
|
|
34
34
|
Homepage = "https://github.com/rcgiri-physics/physverify"
|
|
@@ -49,7 +49,10 @@ def find_specs(root: Path) -> list[Path]:
|
|
|
49
49
|
pyproject = root / "pyproject.toml"
|
|
50
50
|
if pyproject.is_file():
|
|
51
51
|
try:
|
|
52
|
-
|
|
52
|
+
try:
|
|
53
|
+
import tomllib
|
|
54
|
+
except ImportError: # Python 3.10
|
|
55
|
+
import tomli as tomllib
|
|
53
56
|
|
|
54
57
|
config = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool", {}).get("physverify", {})
|
|
55
58
|
except Exception:
|
|
@@ -109,14 +112,18 @@ def hook_main(stdin=None, stderr=None) -> int:
|
|
|
109
112
|
event = json.load(stdin)
|
|
110
113
|
except (json.JSONDecodeError, ValueError):
|
|
111
114
|
return 0 # not a hook event; never block the assistant on our own input problem
|
|
112
|
-
|
|
115
|
+
if not isinstance(event, dict):
|
|
116
|
+
return 0
|
|
117
|
+
tool_input = event.get("tool_input")
|
|
118
|
+
tool_input = tool_input if isinstance(tool_input, dict) else {}
|
|
113
119
|
edited = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
|
|
114
|
-
if edited and not edited.endswith(".py"):
|
|
120
|
+
if not isinstance(edited, str) or (edited and not edited.endswith(".py")):
|
|
115
121
|
return 0
|
|
116
122
|
cwd = Path(event.get("cwd") or ".").resolve()
|
|
117
|
-
|
|
123
|
+
edited_path = (cwd / edited).resolve() if edited else None # a relative path is relative to the session folder
|
|
124
|
+
if edited_path is not None and not edited_path.is_relative_to(cwd):
|
|
118
125
|
return 0 # an edit outside the session folder must not run that folder's code (GCVE-110-VULN-PYPI-2026-000391)
|
|
119
|
-
root = find_project_root(
|
|
126
|
+
root = find_project_root(edited_path.parent if edited_path is not None else cwd)
|
|
120
127
|
if not root.is_relative_to(cwd):
|
|
121
128
|
root = cwd # never climb above the session folder
|
|
122
129
|
specs = find_specs(root)
|
|
@@ -138,8 +145,11 @@ META_VERSION = "io.modelcontextprotocol/protocolVersion"
|
|
|
138
145
|
META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"
|
|
139
146
|
META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
|
|
140
147
|
|
|
141
|
-
|
|
148
|
+
PARSE_ERROR = -32700
|
|
149
|
+
INVALID_REQUEST = -32600
|
|
142
150
|
METHOD_NOT_FOUND = -32601
|
|
151
|
+
INVALID_PARAMS = -32602
|
|
152
|
+
INTERNAL_ERROR = -32603
|
|
143
153
|
UNSUPPORTED_PROTOCOL_VERSION = -32022
|
|
144
154
|
|
|
145
155
|
CACHE_HINTS = {"ttlMs": 300_000, "cacheScope": "public"} # the tool list does not change while the server runs
|
|
@@ -249,6 +259,16 @@ def handle(message: dict):
|
|
|
249
259
|
return _error(msg_id, METHOD_NOT_FOUND, f"method not found: {method}")
|
|
250
260
|
|
|
251
261
|
|
|
262
|
+
def _respond(message):
|
|
263
|
+
"""handle(), but a message that is not an object, or an exception, becomes an error response; the server keeps running."""
|
|
264
|
+
if not isinstance(message, dict): # a batch, a number, a string: not a request this server answers
|
|
265
|
+
return _error(None, INVALID_REQUEST, "invalid request: expected a JSON object")
|
|
266
|
+
try:
|
|
267
|
+
return handle(message)
|
|
268
|
+
except Exception as exc: # report the failure to the client
|
|
269
|
+
return _error(message.get("id"), INTERNAL_ERROR, f"{type(exc).__name__}: {exc}")
|
|
270
|
+
|
|
271
|
+
|
|
252
272
|
def mcp_main(stdin=None, stdout=None) -> int:
|
|
253
273
|
stdin = stdin or sys.stdin
|
|
254
274
|
stdout = stdout or sys.stdout
|
|
@@ -259,12 +279,9 @@ def mcp_main(stdin=None, stdout=None) -> int:
|
|
|
259
279
|
try:
|
|
260
280
|
message = json.loads(line)
|
|
261
281
|
except json.JSONDecodeError:
|
|
262
|
-
response =
|
|
282
|
+
response = _error(None, PARSE_ERROR, "parse error")
|
|
263
283
|
else:
|
|
264
|
-
|
|
265
|
-
response = handle(message)
|
|
266
|
-
except Exception as exc: # keep the server alive; report the failure to the client
|
|
267
|
-
response = {"jsonrpc": "2.0", "id": message.get("id"), "error": {"code": -32603, "message": f"{type(exc).__name__}: {exc}"}}
|
|
284
|
+
response = _respond(message)
|
|
268
285
|
if response is not None:
|
|
269
286
|
stdout.write(json.dumps(response) + "\n")
|
|
270
287
|
stdout.flush()
|
|
@@ -186,6 +186,7 @@ def a_stability_margin(A, b) -> float:
|
|
|
186
186
|
iy = np.polynomial.Polynomial([0, 1j])
|
|
187
187
|
Pi = np.polynomial.Polynomial(P.astype(complex))(iy)
|
|
188
188
|
Qi = np.polynomial.Polynomial(Q.astype(complex))(iy)
|
|
189
|
+
|
|
189
190
|
def sq(p): # |p(iy)|^2 for real y, as a polynomial in y
|
|
190
191
|
return p * np.polynomial.Polynomial(np.conj(p.coef))
|
|
191
192
|
|
|
@@ -205,9 +206,39 @@ def is_stiffly_accurate(A, b, *, tol: float = 1e-12) -> bool:
|
|
|
205
206
|
return bool(np.max(np.abs(np.asarray(A, float)[-1] - np.asarray(b, float))) <= tol)
|
|
206
207
|
|
|
207
208
|
|
|
209
|
+
def square_tableau(A, b, c=None):
|
|
210
|
+
"""(A, b, c) with A made square: a table stored as s x (s - 1) (the last column of an explicit table is
|
|
211
|
+
zero and is often left out) or with an extra FSAL stage that has weight 0 in b is padded with zeros.
|
|
212
|
+
Explicit FSAL tables (Dormand-Prince, Bogacki-Shampine) then check like any other."""
|
|
213
|
+
A = np.atleast_2d(np.asarray(A, dtype=float))
|
|
214
|
+
b = np.asarray(b, dtype=float)
|
|
215
|
+
n = max(A.shape[0], A.shape[1], b.size)
|
|
216
|
+
full = np.zeros((n, n))
|
|
217
|
+
full[:A.shape[0], :A.shape[1]] = A
|
|
218
|
+
bb = np.zeros(n)
|
|
219
|
+
bb[:b.size] = b
|
|
220
|
+
if c is not None:
|
|
221
|
+
cc = np.zeros(n)
|
|
222
|
+
cc[:np.size(c)] = np.asarray(c, dtype=float)
|
|
223
|
+
if np.size(c) < n:
|
|
224
|
+
cc[np.size(c):] = full[np.size(c):].sum(axis=1)
|
|
225
|
+
c = cc
|
|
226
|
+
return full, bb, c
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def symplectic_residual(A, b) -> float:
|
|
230
|
+
"""max |b_i a_ij + b_j a_ji - b_i b_j|: zero exactly for a symplectic Runge-Kutta method (Sanz-Serna 1988;
|
|
231
|
+
Hairer, Lubich and Wanner, Geometric Numerical Integration, Thm. VI.4.3). Gauss methods are symplectic;
|
|
232
|
+
no explicit Runge-Kutta method is."""
|
|
233
|
+
A, b, _ = square_tableau(A, b)
|
|
234
|
+
M = b[:, None] * A + (b[:, None] * A).T - np.outer(b, b)
|
|
235
|
+
return float(np.max(np.abs(M)))
|
|
236
|
+
|
|
237
|
+
|
|
208
238
|
def check_tableau(A, b, c=None, *, claimed: int, label: str = "", tol: float = 1e-8, max_order: int | None = None,
|
|
209
239
|
claimed_linear: int | None = None, claimed_stage: int | None = None, a_stable: bool | None = None,
|
|
210
|
-
l_stable: bool | None = None, stiffly_accurate: bool | None = None
|
|
240
|
+
l_stable: bool | None = None, stiffly_accurate: bool | None = None, embedded=None,
|
|
241
|
+
claimed_embedded: int | None = None, symplectic: bool | None = None) -> Result:
|
|
211
242
|
"""PASS when the table has at least the claimed order and every other stated property; FAIL otherwise.
|
|
212
243
|
|
|
213
244
|
The default tolerance 1e-8 accepts coefficients typed with 9 or 10 digits (a real package had
|
|
@@ -222,8 +253,13 @@ def check_tableau(A, b, c=None, *, claimed: int, label: str = "", tol: float = 1
|
|
|
222
253
|
a_stable |R(z)| <= 1 on Re z <= 0; see `a_stability_margin`
|
|
223
254
|
l_stable A-stable and R(inf) = 0
|
|
224
255
|
stiffly_accurate b equals the last row of A
|
|
256
|
+
symplectic b_i a_ij + b_j a_ji = b_i b_j; see `symplectic_residual`
|
|
225
257
|
True means "the method has it" and False means "the method does not"; a mismatch either way fails.
|
|
258
|
+
embedded the weights of the embedded (error-estimate) method; with `claimed_embedded`, its order
|
|
259
|
+
is checked too, and the error weights b - embedded must sum to 0.
|
|
260
|
+
A table stored as s x (s - 1), or with an FSAL stage of weight 0, is padded first (`square_tableau`).
|
|
226
261
|
"""
|
|
262
|
+
A, b, c = square_tableau(A, b, c)
|
|
227
263
|
top = max_order or claimed + 1
|
|
228
264
|
res = order_residuals(A, b, top)
|
|
229
265
|
p = tableau_order(A, b, max_order=top, tol=tol)
|
|
@@ -258,8 +294,8 @@ def check_tableau(A, b, c=None, *, claimed: int, label: str = "", tol: float = 1
|
|
|
258
294
|
notes.append(f"linear order is at least {pl} (claimed {claimed_linear})")
|
|
259
295
|
else:
|
|
260
296
|
notes.append(f"linear order {pl} confirmed")
|
|
261
|
-
elif linear_order(A, b, max_order=max(p, claimed) + 2, tol=tol) > p:
|
|
262
|
-
notes.append(f"linear order {
|
|
297
|
+
elif (pl := linear_order(A, b, max_order=max(p, claimed) + 2, tol=tol)) > p:
|
|
298
|
+
notes.append(f"linear order {pl} is higher than the order {p}:"
|
|
263
299
|
" a convergence test on y' = lambda*y alone would overstate the order")
|
|
264
300
|
if claimed_stage is not None:
|
|
265
301
|
q = stage_order(A, c, b, max_order=claimed_stage + 1, tol=tol)
|
|
@@ -291,6 +327,78 @@ def check_tableau(A, b, c=None, *, claimed: int, label: str = "", tol: float = 1
|
|
|
291
327
|
else:
|
|
292
328
|
notes.append(f"{'' if has else 'not '}stiffly accurate as stated")
|
|
293
329
|
|
|
330
|
+
if embedded is not None:
|
|
331
|
+
Ae = A
|
|
332
|
+
if np.size(embedded) > A.shape[0]:
|
|
333
|
+
# error weights over one more stage (SciPy's E): the FSAL stage, evaluated at y_{n+1}, has row b
|
|
334
|
+
Ae = np.vstack([np.hstack([A, np.zeros((A.shape[0], 1))]), np.append(b, 0.0)])
|
|
335
|
+
Ae, be, _ = square_tableau(Ae, embedded)
|
|
336
|
+
if abs(be.sum() - 1) > tol:
|
|
337
|
+
fails.append(f"embedded weights sum to {be.sum():.10g}, not 1 (so b - embedded does not sum to 0)")
|
|
338
|
+
if claimed_embedded is not None:
|
|
339
|
+
pe = tableau_order(Ae, be, max_order=claimed_embedded + 1, tol=tol)
|
|
340
|
+
if pe < claimed_embedded:
|
|
341
|
+
fails.append(f"claims embedded order {claimed_embedded} but the embedded weights satisfy the order"
|
|
342
|
+
f" conditions only up to {pe}")
|
|
343
|
+
else:
|
|
344
|
+
notes.append(f"embedded order {pe}" + (f" (claimed {claimed_embedded})" if pe > claimed_embedded else " confirmed"))
|
|
345
|
+
if symplectic is not None:
|
|
346
|
+
r = symplectic_residual(A, b)
|
|
347
|
+
has = r <= tol
|
|
348
|
+
if has != symplectic:
|
|
349
|
+
fails.append(f"stated {'' if symplectic else 'not '}symplectic, but max |b_i a_ij + b_j a_ji - b_i b_j| = {r:.1e}")
|
|
350
|
+
else:
|
|
351
|
+
notes.append(f"{'' if has else 'not '}symplectic as stated")
|
|
352
|
+
|
|
294
353
|
if fails:
|
|
295
354
|
return Result(FAIL, prefix + "; ".join(fails + notes))
|
|
296
355
|
return Result(PASS, prefix + "; ".join(notes))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# Published tables, for comparing a code's copy against them (exact rationals as floats).
|
|
359
|
+
_S3 = np.sqrt(3.0)
|
|
360
|
+
TABLES = {
|
|
361
|
+
"rk4": dict(A=[[0, 0, 0, 0], [0.5, 0, 0, 0], [0, 0.5, 0, 0], [0, 0, 1, 0]], b=[1 / 6, 1 / 3, 1 / 3, 1 / 6],
|
|
362
|
+
c=[0, 0.5, 0.5, 1], order=4),
|
|
363
|
+
# Bogacki and Shampine 1989; SciPy's RK23 (embedded order 2, FSAL)
|
|
364
|
+
"bogacki_shampine_3_2": dict(A=[[0, 0, 0, 0], [1 / 2, 0, 0, 0], [0, 3 / 4, 0, 0], [2 / 9, 1 / 3, 4 / 9, 0]],
|
|
365
|
+
b=[2 / 9, 1 / 3, 4 / 9, 0], c=[0, 1 / 2, 3 / 4, 1], order=3,
|
|
366
|
+
embedded=[7 / 24, 1 / 4, 1 / 3, 1 / 8], embedded_order=2),
|
|
367
|
+
# Dormand and Prince 1980; SciPy's RK45 and MATLAB's ode45 (embedded order 4, FSAL)
|
|
368
|
+
"dormand_prince_5_4": dict(
|
|
369
|
+
A=[[0, 0, 0, 0, 0, 0, 0],
|
|
370
|
+
[1 / 5, 0, 0, 0, 0, 0, 0],
|
|
371
|
+
[3 / 40, 9 / 40, 0, 0, 0, 0, 0],
|
|
372
|
+
[44 / 45, -56 / 15, 32 / 9, 0, 0, 0, 0],
|
|
373
|
+
[19372 / 6561, -25360 / 2187, 64448 / 6561, -212 / 729, 0, 0, 0],
|
|
374
|
+
[9017 / 3168, -355 / 33, 46732 / 5247, 49 / 176, -5103 / 18656, 0, 0],
|
|
375
|
+
[35 / 384, 0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84, 0]],
|
|
376
|
+
b=[35 / 384, 0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84, 0], c=[0, 1 / 5, 3 / 10, 4 / 5, 8 / 9, 1, 1],
|
|
377
|
+
order=5, embedded=[5179 / 57600, 0, 7571 / 16695, 393 / 640, -92097 / 339200, 187 / 2100, 1 / 40],
|
|
378
|
+
embedded_order=4),
|
|
379
|
+
"implicit_midpoint": dict(A=[[0.5]], b=[1.0], c=[0.5], order=2),
|
|
380
|
+
"gauss_legendre_4": dict(A=[[1 / 4, 1 / 4 - _S3 / 6], [1 / 4 + _S3 / 6, 1 / 4]], b=[0.5, 0.5],
|
|
381
|
+
c=[0.5 - _S3 / 6, 0.5 + _S3 / 6], order=4),
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _drop_fsal(A, b, tol: float = 1e-14):
|
|
386
|
+
"""Without a last FSAL stage (row equal to b, weight 0 in b): codes store such tables with or without it."""
|
|
387
|
+
if A.shape[0] > 1 and abs(b[-1]) <= tol and np.allclose(A[-1, :-1], b[:-1], atol=tol, rtol=0):
|
|
388
|
+
return A[:-1, :-1], b[:-1]
|
|
389
|
+
return A, b
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def compare_tableau(A, b, name: str, *, tol: float = 1e-10) -> Result:
|
|
393
|
+
"""A code's copy of a published table (one of `TABLES`) has the published coefficients."""
|
|
394
|
+
ref = TABLES[name]
|
|
395
|
+
A1, b1 = _drop_fsal(*square_tableau(A, b)[:2])
|
|
396
|
+
A2, b2 = _drop_fsal(*square_tableau(ref["A"], ref["b"])[:2])
|
|
397
|
+
if A1.shape != A2.shape:
|
|
398
|
+
return Result(FAIL, f"{name}: {A1.shape[0]} stages, the published table has {A2.shape[0]}")
|
|
399
|
+
dA, db = np.abs(A1 - A2), np.abs(b1 - b2)
|
|
400
|
+
if max(dA.max(), db.max()) <= tol:
|
|
401
|
+
return Result(PASS, f"{name}: coefficients match the published table to {tol:g}")
|
|
402
|
+
i, j = np.unravel_index(int(np.argmax(dA)), dA.shape)
|
|
403
|
+
where = f"A[{i}][{j}] off by {dA[i, j]:.3g}" if dA.max() >= db.max() else f"b[{int(np.argmax(db))}] off by {db.max():.3g}"
|
|
404
|
+
return Result(FAIL, f"{name}: coefficients differ from the published table, largest at {where}")
|
|
@@ -61,6 +61,9 @@ def _cmd_run(args) -> int:
|
|
|
61
61
|
print(f"physverify: {spec}: checks() returned no checks, so nothing was tested", file=sys.stderr)
|
|
62
62
|
return 2
|
|
63
63
|
outcomes += run_checks(checks, args.budget)
|
|
64
|
+
from .specscan import reference_notes
|
|
65
|
+
for note in reference_notes(spec):
|
|
66
|
+
print(f"physverify: note: {note}", file=sys.stderr)
|
|
64
67
|
source = ", ".join(str(Path(s)) for s in args.specs)
|
|
65
68
|
if args.json == "-":
|
|
66
69
|
print(to_json(outcomes, source))
|
|
@@ -125,17 +128,24 @@ def _cmd_smoke(args) -> int:
|
|
|
125
128
|
def _cmd_scan(args) -> int:
|
|
126
129
|
import json
|
|
127
130
|
|
|
131
|
+
from .probes import DEFAULT_PROBES, PROBES, probe
|
|
128
132
|
from .scan import STAGES, WARN, scan, to_dict
|
|
129
133
|
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
known = STAGES + PROBES if args.run else STAGES
|
|
135
|
+
stages = args.only.split(",") if args.only else (STAGES + DEFAULT_PROBES if args.run else STAGES)
|
|
136
|
+
unknown = set(stages) - set(known)
|
|
132
137
|
if unknown:
|
|
133
|
-
|
|
138
|
+
extra = "; the probes need --run" if unknown <= set(PROBES) else ""
|
|
139
|
+
print(f"physverify: unknown stage {', '.join(sorted(unknown))}; choose from {', '.join(known)}{extra}", file=sys.stderr)
|
|
134
140
|
return 2
|
|
135
141
|
if not Path(args.path).exists():
|
|
136
142
|
print(f"physverify: {args.path} does not exist", file=sys.stderr)
|
|
137
143
|
return 2
|
|
138
|
-
findings, unparsed = scan(args.path, stages=stages)
|
|
144
|
+
findings, unparsed = scan(args.path, stages=[s for s in stages if s in STAGES])
|
|
145
|
+
probes = [s for s in stages if s in PROBES]
|
|
146
|
+
if probes:
|
|
147
|
+
print("physverify: --run calls the code; use it only on code you trust or inside the sandbox", file=sys.stderr)
|
|
148
|
+
findings += probe(args.path, probes=probes, timeout=args.timeout)
|
|
139
149
|
if args.json:
|
|
140
150
|
text = json.dumps(to_dict(findings, unparsed), indent=1)
|
|
141
151
|
if args.json == "-":
|
|
@@ -148,8 +158,11 @@ def _cmd_scan(args) -> int:
|
|
|
148
158
|
for u in unparsed:
|
|
149
159
|
print(f"NOTE parse {u}")
|
|
150
160
|
warn = sum(f.level == WARN for f in findings)
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
skip = sum(f.level == "SKIP" for f in findings)
|
|
162
|
+
ran = (f", {skip} skipped (not passed)" if probes else "")
|
|
163
|
+
how = "the probes called the code with small inputs" if probes else "the scan reads the code and runs nothing"
|
|
164
|
+
print(f"{warn} warnings, {len(findings) - warn - skip} notes{ran}, {len(unparsed)} files not parsed"
|
|
165
|
+
f" ({how}; a clean scan means only that these patterns were not found)")
|
|
153
166
|
return 1 if any(f.level == WARN for f in findings) else 0
|
|
154
167
|
|
|
155
168
|
|
|
@@ -204,6 +217,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
204
217
|
sc.add_argument("path", help="a Python file or a folder")
|
|
205
218
|
sc.add_argument("--only", metavar="STAGES", help="comma-separated stages: constants,tables,grid,pairs,names,api,literals,units (default all)")
|
|
206
219
|
sc.add_argument("--json", metavar="FILE", help="write findings as JSON to FILE, or to stdout with -")
|
|
220
|
+
sc.add_argument("--run", action="store_true",
|
|
221
|
+
help="also run the probes smoke and branches, which CALL the code (trusted code or the sandbox only); "
|
|
222
|
+
"the experimental probes edges,deadargs,roundtrip,siblings,known,monotone with --only")
|
|
223
|
+
sc.add_argument("--timeout", type=float, default=60.0, help="with --run: seconds per script or file of functions (default 60)")
|
|
207
224
|
sc.set_defaults(func=_cmd_scan)
|
|
208
225
|
|
|
209
226
|
r = sub.add_parser("references", help="list the exact reference solutions in physverify.references")
|
|
@@ -129,7 +129,10 @@ def scan(path, *, max_rel: float = 0.1) -> list[Note]:
|
|
|
129
129
|
notes = []
|
|
130
130
|
for node in ast.walk(tree):
|
|
131
131
|
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
|
|
132
|
-
|
|
132
|
+
try:
|
|
133
|
+
v = float(node.value)
|
|
134
|
+
except OverflowError: # an integer literal too large for a float is no physical constant
|
|
135
|
+
continue
|
|
133
136
|
hit = check_value(abs(v), max_rel=max_rel)
|
|
134
137
|
if hit and (isinstance(node.value, int) or _digits(v) < 3):
|
|
135
138
|
if not names.get(id(node), set()) & NAMES.get(hit[0], set()):
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Exactness properties every numerical flux of a conservation law must have (Toro, Riemann Solvers and
|
|
2
|
+
Numerical Methods for Fluid Dynamics, 3rd ed., ch. 10-11; LeVeque, Finite Volume Methods, sec. 12.1):
|
|
3
|
+
|
|
4
|
+
- consistency F(W, W) = f(W), the physical flux, for any state W
|
|
5
|
+
- upwinding when every wave speed is positive on both sides, F(WL, WR) = f(WL) (all negative: f(WR));
|
|
6
|
+
Roe, HLL, HLLC and Rusanov fluxes with exact wave-speed bounds satisfy it
|
|
7
|
+
- mirror reflecting the normal velocity and swapping the sides gives F(R WR, R WL) = -R F(WL, WR),
|
|
8
|
+
with R the reflection acting on the flux components
|
|
9
|
+
|
|
10
|
+
Each needs only the flux function and the physical flux written from the equations, not a solver run.
|
|
11
|
+
In the 2026-10 corpus sessions these checks found both bugs in one Euler solver: a Roe flux that broke
|
|
12
|
+
upwinding when density and tangential velocity both jumped, and an HLLE flux built for the wrong fluid.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Callable, Iterable
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .checks import FAIL, PASS, Result
|
|
22
|
+
from .identities import _verdict
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_flux(flux: Callable, physical: Callable, states: Iterable, *, upwind_pairs: Iterable = (),
|
|
26
|
+
downwind_pairs: Iterable = (), mirror: tuple | None = None, rtol: float = 1e-10,
|
|
27
|
+
atol: float = 1e-12) -> Result:
|
|
28
|
+
"""Consistency, upwinding and (optionally) mirror symmetry of a numerical flux `flux(WL, WR)`.
|
|
29
|
+
|
|
30
|
+
`physical(W)` is the exact flux written from the equations; `states` are the states W for the
|
|
31
|
+
consistency check. `upwind_pairs` are (WL, WR) with all wave speeds positive on both sides (F must equal
|
|
32
|
+
f(WL)) and `downwind_pairs` all negative (F must equal f(WR)); the caller builds them from the sound
|
|
33
|
+
speed. `mirror = (reflect, signs)`: `reflect(W)` flips the normal velocity of a state and `signs` holds
|
|
34
|
+
+1 or -1 per flux component (for the 1D Euler equations (-1, +1, -1): mass and energy fluxes change sign,
|
|
35
|
+
the momentum flux does not); the check is F(reflect(WR), reflect(WL)) = signs * F(WL, WR), over all pairs.
|
|
36
|
+
"""
|
|
37
|
+
states = list(states)
|
|
38
|
+
if not states:
|
|
39
|
+
raise ValueError("no states given")
|
|
40
|
+
up, down = list(upwind_pairs), list(downwind_pairs)
|
|
41
|
+
results = [("consistency F(W, W) = f(W)",
|
|
42
|
+
_verdict("consistency", [(f"state {i}", flux(w, w), physical(w)) for i, w in enumerate(states)], rtol, atol))]
|
|
43
|
+
if up:
|
|
44
|
+
results.append(("upwinding F = f(WL) for flow to the right",
|
|
45
|
+
_verdict("upwinding", [(f"pair {i}", flux(a, b), physical(a)) for i, (a, b) in enumerate(up)], rtol, atol)))
|
|
46
|
+
if down:
|
|
47
|
+
results.append(("upwinding F = f(WR) for flow to the left",
|
|
48
|
+
_verdict("upwinding", [(f"pair {i}", flux(a, b), physical(b)) for i, (a, b) in enumerate(down)], rtol, atol)))
|
|
49
|
+
if mirror is not None:
|
|
50
|
+
reflect, signs = mirror
|
|
51
|
+
signs = np.asarray(signs, dtype=float)
|
|
52
|
+
pairs = up + down + list(zip(states, states[1:]))
|
|
53
|
+
cases = [(f"pair {i}", flux(reflect(b), reflect(a)), signs * np.asarray(flux(a, b))) for i, (a, b) in enumerate(pairs)]
|
|
54
|
+
results.append(("mirror symmetry", _verdict("mirror symmetry", cases, rtol, atol)))
|
|
55
|
+
failed = [f"{name}: {r.message}" for name, r in results if not r.ok]
|
|
56
|
+
if failed:
|
|
57
|
+
return Result(FAIL, "; ".join(failed))
|
|
58
|
+
return Result(PASS, "the numerical flux passes " + ", ".join(name for name, _r in results))
|
|
@@ -22,7 +22,11 @@ from .checks import FAIL, PASS, Result
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def _worst(got, ref, rtol: float, atol: float):
|
|
25
|
-
"""Largest violation of |got - ref| <= atol + rtol |ref|, as (excess, abs error, where).
|
|
25
|
+
"""Largest violation of |got - ref| <= atol + rtol |ref|, as (excess, abs error, where, got there, ref there).
|
|
26
|
+
|
|
27
|
+
`where` indexes the broadcast shape, so the values there are returned too: indexing the original
|
|
28
|
+
`got` with it fails when a scalar was compared with an array.
|
|
29
|
+
"""
|
|
26
30
|
g = np.asarray(got)
|
|
27
31
|
r = np.asarray(ref)
|
|
28
32
|
if g.shape != r.shape:
|
|
@@ -34,12 +38,12 @@ def _worst(got, ref, rtol: float, atol: float):
|
|
|
34
38
|
tol = atol + rtol * np.abs(r)
|
|
35
39
|
excess = err - tol
|
|
36
40
|
if excess.size == 0:
|
|
37
|
-
return -np.inf, 0.0, ()
|
|
41
|
+
return -np.inf, 0.0, (), got, ref
|
|
38
42
|
if not np.all(np.isfinite(err)):
|
|
39
43
|
i = np.unravel_index(int(np.argmax(~np.isfinite(err))), err.shape)
|
|
40
|
-
return np.inf, np.inf, i
|
|
44
|
+
return np.inf, np.inf, i, (g[i] if i else got), (r[i] if i else ref)
|
|
41
45
|
i = np.unravel_index(int(np.argmax(excess)), excess.shape)
|
|
42
|
-
return float(excess[i]), float(err[i]), i
|
|
46
|
+
return float(excess[i]), float(err[i]), i, (g[i] if i else got), (r[i] if i else ref)
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
def _verdict(what: str, cases, rtol: float, atol: float) -> Result:
|
|
@@ -48,18 +52,16 @@ def _verdict(what: str, cases, rtol: float, atol: float) -> Result:
|
|
|
48
52
|
n = 0
|
|
49
53
|
for label, got, ref in cases:
|
|
50
54
|
n += 1
|
|
51
|
-
excess, err, where = _worst(got, ref, rtol, atol)
|
|
55
|
+
excess, err, where, g, r = _worst(got, ref, rtol, atol)
|
|
52
56
|
if worst is None or excess > worst[0]:
|
|
53
|
-
worst = (excess, err, label, where,
|
|
57
|
+
worst = (excess, err, label, where, g, r)
|
|
54
58
|
if worst is None:
|
|
55
59
|
raise ValueError(f"{what}: no inputs given")
|
|
56
|
-
excess, err, label, where,
|
|
60
|
+
excess, err, label, where, g, r = worst
|
|
57
61
|
at = f" at index {tuple(int(k) for k in where)}" if where else ""
|
|
58
62
|
detail = f"worst case {label}{at}: |difference| = {err:.3g} (rtol {rtol:g}, atol {atol:g})"
|
|
59
63
|
if excess <= 0:
|
|
60
64
|
return Result(PASS, f"{what}: {n} case{'s' if n != 1 else ''} within tolerance; {detail}")
|
|
61
|
-
g = np.asarray(got)[where] if where else got
|
|
62
|
-
r = np.asarray(ref)[where] if where else ref
|
|
63
65
|
return Result(FAIL, f"{what}: {detail}; got {g!r}, expected {r!r}")
|
|
64
66
|
|
|
65
67
|
|
|
@@ -155,3 +157,103 @@ def check_unitary(matrix, *, atol: float = 1e-10) -> Result:
|
|
|
155
157
|
eye = np.eye(m.shape[-1])
|
|
156
158
|
prod = np.einsum("...ji,...jk->...ik", m.conj(), m)
|
|
157
159
|
return _verdict("unitarity M^H M = I", [("matrix", prod, np.broadcast_to(eye, prod.shape))], 0.0, atol)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _passes(outcome, rtol: float, atol: float) -> tuple[bool, str]:
|
|
163
|
+
"""A test outcome as (passed, detail): a Result, a bool, or a (got, ref) pair compared within tolerance."""
|
|
164
|
+
if isinstance(outcome, Result):
|
|
165
|
+
return outcome.ok, outcome.message
|
|
166
|
+
if isinstance(outcome, tuple) and len(outcome) == 2:
|
|
167
|
+
excess, err, _where, _g, _r = _worst(outcome[0], outcome[1], rtol, atol)
|
|
168
|
+
return excess <= 0, f"|difference| = {err:.3g}"
|
|
169
|
+
return bool(outcome), ""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def vary_one(test: Callable, defaults: dict, changes: dict, *, rtol: float = 1e-9, atol: float = 0.0) -> Result:
|
|
173
|
+
"""Re-run a test that passes at the default parameters with one parameter changed at a time.
|
|
174
|
+
|
|
175
|
+
`test(**params)` returns a Result, a bool, or a (got, ref) pair with the reference from the physics.
|
|
176
|
+
`defaults` are the usual values (often symmetric ones: Lx = Ly, nx = ny, zero boundary data, s polarisation,
|
|
177
|
+
vacuum); `changes` maps a parameter to a value, or a list of values, to try instead while the others keep
|
|
178
|
+
their defaults. FAIL names each change that breaks the test.
|
|
179
|
+
|
|
180
|
+
In the 2026-10 corpus sessions 7 of the confirmed bugs passed every check at the example values and
|
|
181
|
+
appeared only when one parameter left them: a y-derivative scaled with Lx (only when Lx != Ly), a
|
|
182
|
+
Neumann row that dropped the boundary value (only when g != 0), the p-polarisation path of a grating
|
|
183
|
+
solver, an inertia formula right for rectangles only, a factor that cancels for low-loss media.
|
|
184
|
+
"""
|
|
185
|
+
ok, detail = _passes(test(**defaults), rtol, atol)
|
|
186
|
+
if not ok:
|
|
187
|
+
return Result(FAIL, f"fails already at the defaults {defaults}" + (f": {detail}" if detail else ""))
|
|
188
|
+
broken, tried = [], 0
|
|
189
|
+
for name, values in changes.items():
|
|
190
|
+
if name not in defaults:
|
|
191
|
+
raise ValueError(f"{name} is not one of the default parameters")
|
|
192
|
+
for v in values if isinstance(values, (list, tuple)) else [values]:
|
|
193
|
+
tried += 1
|
|
194
|
+
try:
|
|
195
|
+
ok, detail = _passes(test(**{**defaults, name: v}), rtol, atol)
|
|
196
|
+
except Exception as exc: # noqa: BLE001 - an exception fails that change, not the helper
|
|
197
|
+
ok, detail = False, f"{type(exc).__name__}: {exc}"
|
|
198
|
+
if not ok:
|
|
199
|
+
broken.append(f"{name} = {v!r}" + (f" ({detail})" if detail else ""))
|
|
200
|
+
if broken:
|
|
201
|
+
return Result(FAIL, f"passes at the defaults but fails when one parameter changes: {'; '.join(broken)}")
|
|
202
|
+
return Result(PASS, f"passes at the defaults and with each of {tried} one-parameter changes ({', '.join(changes)})")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_FACTORS = {"2": 2.0, "1/2": 0.5, "pi": np.pi, "1/pi": 1 / np.pi, "2 pi": 2 * np.pi, "1/(2 pi)": 1 / (2 * np.pi),
|
|
206
|
+
"sqrt(2)": np.sqrt(2), "1/sqrt(2)": 1 / np.sqrt(2), "4 pi": 4 * np.pi, "1/(4 pi)": 1 / (4 * np.pi),
|
|
207
|
+
"-1": -1.0, "4": 4.0, "1/4": 0.25, "3": 3.0, "1/3": 1 / 3}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def check_scale(got, ref, *, rtol: float = 1e-6, min_correlation: float = 0.999) -> Result:
|
|
211
|
+
"""The output has the right size, not only the right shape: got = k * ref with k = 1.
|
|
212
|
+
|
|
213
|
+
Fits the single factor k (least squares) and the correlation of got with ref. A correlation near 1
|
|
214
|
+
with k != 1 is a scale or units error (a missing 1/(2 dx), a missing time step, a factor 1/2); the
|
|
215
|
+
message names a nearby simple factor (2, pi, sqrt 2, ...) or gives k to match against a grid spacing
|
|
216
|
+
or a sampling rate. In the 2026-10 corpus sessions the ratio alone pointed to the bug in four
|
|
217
|
+
repositories. A low correlation means the shape itself is wrong, and a plain comparison says more.
|
|
218
|
+
"""
|
|
219
|
+
g = np.ravel(np.asarray(got, dtype=complex))
|
|
220
|
+
r = np.ravel(np.asarray(ref, dtype=complex))
|
|
221
|
+
if g.shape != r.shape:
|
|
222
|
+
raise ValueError(f"shapes differ: got {np.shape(got)}, reference {np.shape(ref)}")
|
|
223
|
+
rr = np.vdot(r, r).real
|
|
224
|
+
if rr == 0:
|
|
225
|
+
raise ValueError("the reference is zero everywhere, so no scale can be fitted")
|
|
226
|
+
k = np.vdot(r, g) / rr
|
|
227
|
+
gg = np.vdot(g, g).real
|
|
228
|
+
corr = abs(np.vdot(r, g)) / np.sqrt(rr * gg) if gg > 0 else 0.0
|
|
229
|
+
kshow = f"{k.real:.6g}" if abs(k.imag) <= 1e-12 * abs(k) else f"{k:.6g}"
|
|
230
|
+
if corr < min_correlation:
|
|
231
|
+
return Result(FAIL, f"output and reference differ in shape, not only in scale (correlation {corr:.4f},"
|
|
232
|
+
f" best factor {kshow}); compare them directly")
|
|
233
|
+
if abs(k - 1) <= rtol:
|
|
234
|
+
return Result(PASS, f"output = {kshow} x reference (correlation {corr:.6f})")
|
|
235
|
+
near = [n for n, v in _FACTORS.items() if abs(k - v) <= 1e-3 * abs(v)]
|
|
236
|
+
hint = f", which is {near[0]}" if near else ""
|
|
237
|
+
return Result(FAIL, f"output = {kshow} x reference{hint} (correlation {corr:.6f}): the shape is right but the scale"
|
|
238
|
+
" is not; look for a missing factor (grid spacing, time step, sampling rate, 2, pi) or a units slip")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def check_vec_convention(superop, f: Callable, x, *, expected: str | None = None, atol: float = 1e-10) -> Result:
|
|
242
|
+
"""Which vectorisation a superoperator follows: superop @ vec(X) == vec(f(X)), with vec in C (row-major)
|
|
243
|
+
or F (column-major) order.
|
|
244
|
+
|
|
245
|
+
Kronecker constructions of Liouvillians and other superoperators depend on the convention
|
|
246
|
+
(vec(A X B) = (B^T kron A) vec(X) holds for column stacking); code that builds S for one and reshapes
|
|
247
|
+
with the other gives a wrong but well-formed answer (corpus repo L204). With `expected` ('C' or 'F'),
|
|
248
|
+
FAIL unless S follows it; without, PASS when S follows either, and say which.
|
|
249
|
+
"""
|
|
250
|
+
S = np.asarray(superop)
|
|
251
|
+
X = np.asarray(x)
|
|
252
|
+
want = np.asarray(f(X))
|
|
253
|
+
match = [o for o in ("C", "F") if np.allclose(S @ X.ravel(order=o), want.ravel(order=o), atol=atol, rtol=0)]
|
|
254
|
+
if not match:
|
|
255
|
+
return Result(FAIL, "the superoperator matches f(X) in neither C (row-major) nor F (column-major) vectorisation")
|
|
256
|
+
which = " and ".join(match)
|
|
257
|
+
if expected is not None and expected.upper() not in match:
|
|
258
|
+
return Result(FAIL, f"the superoperator follows {which}-order vectorisation, not the expected {expected.upper()}")
|
|
259
|
+
return Result(PASS, f"the superoperator follows {which}-order vectorisation (row-major is C, column-major is F)")
|