physverify 0.6.0__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.
Files changed (73) hide show
  1. {physverify-0.6.0/src/physverify.egg-info → physverify-0.7.0}/PKG-INFO +3 -3
  2. {physverify-0.6.0 → physverify-0.7.0}/README.md +2 -2
  3. {physverify-0.6.0 → physverify-0.7.0}/pyproject.toml +1 -1
  4. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/__init__.py +1 -1
  5. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/agent.py +24 -10
  6. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/butcher.py +111 -3
  7. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/cli.py +3 -0
  8. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/constants.py +4 -1
  9. physverify-0.7.0/src/physverify/fluxes.py +58 -0
  10. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/identities.py +111 -9
  11. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/lint.py +64 -8
  12. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/order.py +28 -0
  13. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/probes.py +10 -4
  14. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/references.py +20 -0
  15. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/report.py +1 -1
  16. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/scan.py +90 -3
  17. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/smoke.py +3 -2
  18. physverify-0.7.0/src/physverify/specscan.py +67 -0
  19. physverify-0.7.0/src/physverify/stencils.py +69 -0
  20. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/stats.py +3 -1
  21. {physverify-0.6.0 → physverify-0.7.0/src/physverify.egg-info}/PKG-INFO +3 -3
  22. {physverify-0.6.0 → physverify-0.7.0}/src/physverify.egg-info/SOURCES.txt +5 -0
  23. physverify-0.7.0/tests/test_awkward_input.py +151 -0
  24. {physverify-0.6.0 → physverify-0.7.0}/tests/test_batch8_lessons.py +18 -0
  25. physverify-0.7.0/tests/test_helpers_2026_10.py +146 -0
  26. physverify-0.7.0/tests/test_roadmap_0_7.py +83 -0
  27. {physverify-0.6.0 → physverify-0.7.0}/tests/test_scan.py +123 -0
  28. physverify-0.6.0/tests/test_awkward_input.py +0 -74
  29. {physverify-0.6.0 → physverify-0.7.0}/LICENSE +0 -0
  30. {physverify-0.6.0 → physverify-0.7.0}/setup.cfg +0 -0
  31. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/__main__.py +0 -0
  32. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/checks.py +0 -0
  33. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/demo.py +0 -0
  34. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/loaders.py +0 -0
  35. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/mms.py +0 -0
  36. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/pytest_plugin.py +0 -0
  37. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/scaffold.py +0 -0
  38. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/steppers.py +0 -0
  39. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/__init__.py +0 -0
  40. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/calibrate.py +0 -0
  41. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/checks.py +0 -0
  42. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/exact.py +0 -0
  43. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/ising.py +0 -0
  44. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/reference.py +0 -0
  45. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/stochastic/sampler.py +0 -0
  46. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/studies.py +0 -0
  47. {physverify-0.6.0 → physverify-0.7.0}/src/physverify/symbolic.py +0 -0
  48. {physverify-0.6.0 → physverify-0.7.0}/src/physverify.egg-info/dependency_links.txt +0 -0
  49. {physverify-0.6.0 → physverify-0.7.0}/src/physverify.egg-info/entry_points.txt +0 -0
  50. {physverify-0.6.0 → physverify-0.7.0}/src/physverify.egg-info/requires.txt +0 -0
  51. {physverify-0.6.0 → physverify-0.7.0}/src/physverify.egg-info/top_level.txt +0 -0
  52. {physverify-0.6.0 → physverify-0.7.0}/tests/test_agent.py +0 -0
  53. {physverify-0.6.0 → physverify-0.7.0}/tests/test_batch7_lessons.py +0 -0
  54. {physverify-0.6.0 → physverify-0.7.0}/tests/test_butcher_properties.py +0 -0
  55. {physverify-0.6.0 → physverify-0.7.0}/tests/test_cli.py +0 -0
  56. {physverify-0.6.0 → physverify-0.7.0}/tests/test_corpus_lessons.py +0 -0
  57. {physverify-0.6.0 → physverify-0.7.0}/tests/test_docs_examples.py +0 -0
  58. {physverify-0.6.0 → physverify-0.7.0}/tests/test_figures.py +0 -0
  59. {physverify-0.6.0 → physverify-0.7.0}/tests/test_ising.py +0 -0
  60. {physverify-0.6.0 → physverify-0.7.0}/tests/test_method_bugs.py +0 -0
  61. {physverify-0.6.0 → physverify-0.7.0}/tests/test_mms.py +0 -0
  62. {physverify-0.6.0 → physverify-0.7.0}/tests/test_new_problems.py +0 -0
  63. {physverify-0.6.0 → physverify-0.7.0}/tests/test_order.py +0 -0
  64. {physverify-0.6.0 → physverify-0.7.0}/tests/test_probes.py +0 -0
  65. {physverify-0.6.0 → physverify-0.7.0}/tests/test_properties.py +0 -0
  66. {physverify-0.6.0 → physverify-0.7.0}/tests/test_real_projects.py +0 -0
  67. {physverify-0.6.0 → physverify-0.7.0}/tests/test_reference_problems.py +0 -0
  68. {physverify-0.6.0 → physverify-0.7.0}/tests/test_references.py +0 -0
  69. {physverify-0.6.0 → physverify-0.7.0}/tests/test_scaffold.py +0 -0
  70. {physverify-0.6.0 → physverify-0.7.0}/tests/test_seeded_bugs.py +0 -0
  71. {physverify-0.6.0 → physverify-0.7.0}/tests/test_studies.py +0 -0
  72. {physverify-0.6.0 → physverify-0.7.0}/tests/test_v040_helpers.py +0 -0
  73. {physverify-0.6.0 → physverify-0.7.0}/tests/test_versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: physverify
3
- Version: 0.6.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
@@ -46,7 +46,7 @@ Dynamic: license-file
46
46
 
47
47
  **Checks that a simulation solves the equations it claims, at the order of accuracy it claims, not just that it runs.**
48
48
 
49
- physverify is actively evolving (v0.6.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.
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.
50
50
 
51
51
  ![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)
52
52
 
@@ -73,7 +73,7 @@ New to this? [Start here](https://rcgiri-physics.github.io/physverify/start-here
73
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):
74
74
 
75
75
  ```yaml
76
- - uses: rcgiri-physics/physverify@v0.6.0
76
+ - uses: rcgiri-physics/physverify@v0.7.0
77
77
  with:
78
78
  specs: tests/verify_*.py
79
79
  ```
@@ -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.6.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.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
  ![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.6.0
34
+ - uses: rcgiri-physics/physverify@v0.7.0
35
35
  with:
36
36
  specs: tests/verify_*.py
37
37
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "physverify"
7
- version = "0.6.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"
@@ -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.6.0"
9
+ __version__ = "0.7.0"
10
10
 
11
11
  __all__ = [
12
12
  "Check",
@@ -112,14 +112,18 @@ def hook_main(stdin=None, stderr=None) -> int:
112
112
  event = json.load(stdin)
113
113
  except (json.JSONDecodeError, ValueError):
114
114
  return 0 # not a hook event; never block the assistant on our own input problem
115
- tool_input = event.get("tool_input") or {}
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 {}
116
119
  edited = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
117
- if edited and not edited.endswith(".py"):
120
+ if not isinstance(edited, str) or (edited and not edited.endswith(".py")):
118
121
  return 0
119
122
  cwd = Path(event.get("cwd") or ".").resolve()
120
- if edited and not Path(edited).resolve().is_relative_to(cwd):
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):
121
125
  return 0 # an edit outside the session folder must not run that folder's code (GCVE-110-VULN-PYPI-2026-000391)
122
- root = find_project_root(Path(edited).parent if edited else cwd)
126
+ root = find_project_root(edited_path.parent if edited_path is not None else cwd)
123
127
  if not root.is_relative_to(cwd):
124
128
  root = cwd # never climb above the session folder
125
129
  specs = find_specs(root)
@@ -141,8 +145,11 @@ META_VERSION = "io.modelcontextprotocol/protocolVersion"
141
145
  META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"
142
146
  META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
143
147
 
144
- INVALID_PARAMS = -32602
148
+ PARSE_ERROR = -32700
149
+ INVALID_REQUEST = -32600
145
150
  METHOD_NOT_FOUND = -32601
151
+ INVALID_PARAMS = -32602
152
+ INTERNAL_ERROR = -32603
146
153
  UNSUPPORTED_PROTOCOL_VERSION = -32022
147
154
 
148
155
  CACHE_HINTS = {"ttlMs": 300_000, "cacheScope": "public"} # the tool list does not change while the server runs
@@ -252,6 +259,16 @@ def handle(message: dict):
252
259
  return _error(msg_id, METHOD_NOT_FOUND, f"method not found: {method}")
253
260
 
254
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
+
255
272
  def mcp_main(stdin=None, stdout=None) -> int:
256
273
  stdin = stdin or sys.stdin
257
274
  stdout = stdout or sys.stdout
@@ -262,12 +279,9 @@ def mcp_main(stdin=None, stdout=None) -> int:
262
279
  try:
263
280
  message = json.loads(line)
264
281
  except json.JSONDecodeError:
265
- response = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}
282
+ response = _error(None, PARSE_ERROR, "parse error")
266
283
  else:
267
- try:
268
- response = handle(message)
269
- except Exception as exc: # keep the server alive; report the failure to the client
270
- response = {"jsonrpc": "2.0", "id": message.get("id"), "error": {"code": -32603, "message": f"{type(exc).__name__}: {exc}"}}
284
+ response = _respond(message)
271
285
  if response is not None:
272
286
  stdout.write(json.dumps(response) + "\n")
273
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) -> Result:
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 {linear_order(A, b, max_order=max(p, claimed) + 2, tol=tol)} is higher than the order {p}:"
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))
@@ -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
- v = float(node.value)
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, got, ref)
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, got, ref = worst
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)")