phaseprobe 0.2.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/CHANGELOG.md +12 -0
  2. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/CITATION.cff +1 -2
  3. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/PKG-INFO +20 -1
  4. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/README.md +19 -0
  5. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/pyproject.toml +1 -1
  6. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/__init__.py +1 -1
  7. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/cli.py +28 -1
  8. phaseprobe-0.3.0/src/phaseprobe/generate.py +377 -0
  9. phaseprobe-0.3.0/tests/test_artifacts_replay.py +229 -0
  10. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/test_cli.py +27 -0
  11. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/uv.lock +1 -1
  12. phaseprobe-0.2.1/src/phaseprobe/generate.py +0 -88
  13. phaseprobe-0.2.1/tests/test_artifacts_replay.py +0 -99
  14. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  15. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  17. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/good_first_accessibility.yml +0 -0
  18. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/good_first_pendulum.yml +0 -0
  19. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/ISSUE_TEMPLATE/good_first_schema.yml +0 -0
  20. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/workflows/ci.yml +0 -0
  22. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/workflows/publish-pypi.yml +0 -0
  23. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.github/workflows/release.yml +0 -0
  24. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/.gitignore +0 -0
  25. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/AGENTS.md +0 -0
  26. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/ARCHITECTURE.md +0 -0
  27. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  28. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/CONTRIBUTING.md +0 -0
  29. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/LICENSE +0 -0
  30. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/LIMITATIONS.md +0 -0
  31. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/PRIOR_ART.md +0 -0
  32. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/ROADMAP.md +0 -0
  33. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/SCIENTIFIC_METHODS.md +0 -0
  34. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/SECURITY.md +0 -0
  35. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/demo-session.txt +0 -0
  36. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/demo-static.png +0 -0
  37. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/demo.gif +0 -0
  38. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/scipy-demo-session.txt +0 -0
  39. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/scipy-demo-static.png +0 -0
  40. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/scipy-demo.gif +0 -0
  41. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/assets/social-preview.jpg +0 -0
  42. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/docs/SCIPY_SOLVE_IVP_AUDIT.md +0 -0
  43. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/docs/upstream/SCIPY_INTEGRATION_PROPOSAL.md +0 -0
  44. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/README.md +0 -0
  45. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/logistic-negative.json +0 -0
  46. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/logistic-scan.json +0 -0
  47. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/lorenz-negative.json +0 -0
  48. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/lorenz-perturb.json +0 -0
  49. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/predator-prey-check.json +0 -0
  50. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/predator-prey-negative.json +0 -0
  51. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/toggle-negative.json +0 -0
  52. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/configs/toggle-perturb.json +0 -0
  53. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/scipy/lorenz-negative.json +0 -0
  54. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/scipy/lorenz.json +0 -0
  55. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/scipy/predator-prey-coarse.json +0 -0
  56. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/scipy/predator-prey.json +0 -0
  57. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/examples/scipy/report.html +0 -0
  58. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/audit_package.py +0 -0
  59. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/check_links.py +0 -0
  60. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/generate_demo.py +0 -0
  61. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/generate_scipy_demo.py +0 -0
  62. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/hygiene.py +0 -0
  63. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/installed_smoke.py +0 -0
  64. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/scripts/verify_artifacts.py +0 -0
  65. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/__main__.py +0 -0
  66. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/adapters/__init__.py +0 -0
  67. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/adapters/loader.py +0 -0
  68. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/adapters/scipy.py +0 -0
  69. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/api.py +0 -0
  70. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/artifacts.py +0 -0
  71. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/config.py +0 -0
  72. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/__init__.py +0 -0
  73. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/__init__.py +0 -0
  74. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/logistic-negative.json +0 -0
  75. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/logistic-scan.json +0 -0
  76. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/lorenz-negative.json +0 -0
  77. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/lorenz-perturb.json +0 -0
  78. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/predator-prey-check.json +0 -0
  79. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/predator-prey-negative.json +0 -0
  80. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/scipy-lorenz-negative.json +0 -0
  81. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/scipy-lorenz.json +0 -0
  82. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/scipy-predator-prey-coarse.json +0 -0
  83. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/scipy-predator-prey.json +0 -0
  84. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/toggle-negative.json +0 -0
  85. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/data/examples/toggle-perturb.json +0 -0
  86. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/engine.py +0 -0
  87. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/errors.py +0 -0
  88. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/examples/__init__.py +0 -0
  89. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/examples/scipy_models.py +0 -0
  90. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/__init__.py +0 -0
  91. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/_common.py +0 -0
  92. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/logistic.py +0 -0
  93. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/lorenz.py +0 -0
  94. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/predator_prey.py +0 -0
  95. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/models/toggle.py +0 -0
  96. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/py.typed +0 -0
  97. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/replay.py +0 -0
  98. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/reporting.py +0 -0
  99. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/src/phaseprobe/types.py +0 -0
  100. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/conftest.py +0 -0
  101. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/fixtures/logistic_map-replay.json +0 -0
  102. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/fixtures/lorenz_scipy-replay.json +0 -0
  103. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/fixtures/predator_prey_scipy-replay.json +0 -0
  104. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/test_logistic_map_transition.py +0 -0
  105. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/test_lorenz_scipy_transition.py +0 -0
  106. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/generated/test_predator_prey_scipy_transition.py +0 -0
  107. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/test_config.py +0 -0
  108. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/test_engine.py +0 -0
  109. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/test_optional_dependency.py +0 -0
  110. {phaseprobe-0.2.1 → phaseprobe-0.3.0}/tests/test_scipy_adapter.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes are documented here. PhaseProbe follows semantic versioning.
4
4
 
5
+ ## Unreleased
6
+
7
+ - Added bounded offline `verify-evidence` validation for generated pytest evidence, replay
8
+ decisions, artifact hashes, safe relative paths, and the fixed regression template.
9
+ - The unreleased package identity is now `0.3.0` for the new generated-evidence contract.
10
+ - `generate-test` now writes a path-portable claim/decision evidence record that binds the
11
+ validated replay verdict to SHA-256 hashes of the copied fixture and generated pytest, the
12
+ exact reproduction command, PhaseProbe attribution, and explicit scientific limitations.
13
+ - Identical generated evidence is idempotent; conflicting evidence is rejected without overwrite.
14
+ - Invariant-only fixtures now use the narrower `simulation-replay-regression` claim kind, and the
15
+ unsigned evidence-integrity boundary is explicit.
16
+
5
17
  ## 0.2.1 — 2026-08-31
6
18
 
7
19
  - Fixed Issue #4: all four SciPy quick-start configurations now ship inside the importable
@@ -4,8 +4,7 @@ title: "PhaseProbe"
4
4
  type: software
5
5
  authors:
6
6
  - name: "Ali"
7
- version: 0.2.1
8
- date-released: 2026-08-31
7
+ version: 0.3.0
9
8
  url: "https://github.com/aliengineering-byte/phaseprobe"
10
9
  repository-code: "https://github.com/aliengineering-byte/phaseprobe"
11
10
  license: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: phaseprobe
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Find reproducible qualitative simulation transitions and turn them into regression tests.
5
5
  Project-URL: Documentation, https://github.com/aliengineering-byte/phaseprobe#readme
6
6
  Project-URL: Issues, https://github.com/aliengineering-byte/phaseprobe/issues
@@ -79,6 +79,7 @@ pip install phaseprobe pytest
79
79
  phaseprobe scan --example logistic
80
80
  phaseprobe replay .phaseprobe/runs/<run-id>/replay.json
81
81
  phaseprobe generate-test .phaseprobe/runs/<run-id>/replay.json
82
+ phaseprobe verify-evidence tests/generated/logistic_map-pytest-evidence.json
82
83
  python -m pytest -q tests/generated
83
84
  phaseprobe report .phaseprobe/runs/<run-id>
84
85
  ```
@@ -172,6 +173,7 @@ does not import it. See [the audited contract](docs/SCIPY_SOLVE_IVP_AUDIT.md),
172
173
  | `check` | Execute a declared configuration policy for CI | Exit `1` only when policy fails |
173
174
  | `replay` | Validate fixture integrity and re-execute model, parameters, seed, initial state, tolerances, and retention | Declared `exact` or `tolerance` comparison passes |
174
175
  | `generate-test` | Validate and copy a fixture into a non-extensible pytest template without conflicting overwrites | Executable test under `tests/generated/` |
176
+ | `verify-evidence` | Re-derive claims/replay decisions and validate bounded strict JSON, artifact hashes, paths, and the fixed template offline | Stable verified or invalid-input exit |
175
177
  | `report` | Regenerate terminal, versioned JSON, and self-contained offline HTML evidence | Local report files |
176
178
 
177
179
  Common options:
@@ -218,6 +220,22 @@ manifest.json sizes and SHA-256 hashes
218
220
 
219
221
  Model names used for generated test paths are sanitized. The generated source comes from a fixed template; configuration strings never become executable Python.
220
222
 
223
+ `generate-test` validates the replay before writing anything. Alongside the fixed pytest template
224
+ and copied fixture, it writes `<model>-pytest-evidence.json`: a path-portable claim/decision record
225
+ containing repository/version attribution, the declared replay comparisons, SHA-256 hashes for
226
+ both executable artifacts, the exact pytest command, and explicit scientific limitations.
227
+ Run `phaseprobe verify-evidence <evidence.json>` before pytest to validate the strict schema,
228
+ recompute the hashes, re-execute the replay policy, derive the claim/decision fields, and confirm
229
+ that the regression still matches PhaseProbe's fixed template. Validation is offline, bounded to a
230
+ 1 MiB evidence document and 32 MiB per generated artifact, rejects duplicate keys and unsafe paths,
231
+ and exits `2` for invalid input.
232
+ The record is unsigned: consumers must recompute the two artifact hashes to detect a recomputed or
233
+ substituted record. It is portable integrity metadata, not an authentication signature.
234
+
235
+ If `generate-test` reports a conflict, it has left the existing output untouched. An identical
236
+ artifact can be generated again safely; for different content, inspect the existing files and
237
+ choose a fresh output directory (or remove them only after deciding they are no longer needed).
238
+
221
239
  ## What PhaseProbe adds—and what it does not
222
240
 
223
241
  | existing category | established strength | PhaseProbe’s narrower job |
@@ -299,6 +317,7 @@ python scripts/hygiene.py
299
317
  ```
300
318
 
301
319
  See [CONTRIBUTING.md](CONTRIBUTING.md) and the concrete good-first-issue templates in `.github/ISSUE_TEMPLATE/`.
320
+ Report a reproducible defect with the [bug form](https://github.com/aliengineering-byte/phaseprobe/issues/new?template=bug_report.yml), or propose a bounded adapter or workflow with the [feature form](https://github.com/aliengineering-byte/phaseprobe/issues/new?template=feature_request.yml). Please do not include secrets or private model data.
302
321
 
303
322
  ## License and citation
304
323
 
@@ -41,6 +41,7 @@ pip install phaseprobe pytest
41
41
  phaseprobe scan --example logistic
42
42
  phaseprobe replay .phaseprobe/runs/<run-id>/replay.json
43
43
  phaseprobe generate-test .phaseprobe/runs/<run-id>/replay.json
44
+ phaseprobe verify-evidence tests/generated/logistic_map-pytest-evidence.json
44
45
  python -m pytest -q tests/generated
45
46
  phaseprobe report .phaseprobe/runs/<run-id>
46
47
  ```
@@ -134,6 +135,7 @@ does not import it. See [the audited contract](docs/SCIPY_SOLVE_IVP_AUDIT.md),
134
135
  | `check` | Execute a declared configuration policy for CI | Exit `1` only when policy fails |
135
136
  | `replay` | Validate fixture integrity and re-execute model, parameters, seed, initial state, tolerances, and retention | Declared `exact` or `tolerance` comparison passes |
136
137
  | `generate-test` | Validate and copy a fixture into a non-extensible pytest template without conflicting overwrites | Executable test under `tests/generated/` |
138
+ | `verify-evidence` | Re-derive claims/replay decisions and validate bounded strict JSON, artifact hashes, paths, and the fixed template offline | Stable verified or invalid-input exit |
137
139
  | `report` | Regenerate terminal, versioned JSON, and self-contained offline HTML evidence | Local report files |
138
140
 
139
141
  Common options:
@@ -180,6 +182,22 @@ manifest.json sizes and SHA-256 hashes
180
182
 
181
183
  Model names used for generated test paths are sanitized. The generated source comes from a fixed template; configuration strings never become executable Python.
182
184
 
185
+ `generate-test` validates the replay before writing anything. Alongside the fixed pytest template
186
+ and copied fixture, it writes `<model>-pytest-evidence.json`: a path-portable claim/decision record
187
+ containing repository/version attribution, the declared replay comparisons, SHA-256 hashes for
188
+ both executable artifacts, the exact pytest command, and explicit scientific limitations.
189
+ Run `phaseprobe verify-evidence <evidence.json>` before pytest to validate the strict schema,
190
+ recompute the hashes, re-execute the replay policy, derive the claim/decision fields, and confirm
191
+ that the regression still matches PhaseProbe's fixed template. Validation is offline, bounded to a
192
+ 1 MiB evidence document and 32 MiB per generated artifact, rejects duplicate keys and unsafe paths,
193
+ and exits `2` for invalid input.
194
+ The record is unsigned: consumers must recompute the two artifact hashes to detect a recomputed or
195
+ substituted record. It is portable integrity metadata, not an authentication signature.
196
+
197
+ If `generate-test` reports a conflict, it has left the existing output untouched. An identical
198
+ artifact can be generated again safely; for different content, inspect the existing files and
199
+ choose a fresh output directory (or remove them only after deciding they are no longer needed).
200
+
183
201
  ## What PhaseProbe adds—and what it does not
184
202
 
185
203
  | existing category | established strength | PhaseProbe’s narrower job |
@@ -261,6 +279,7 @@ python scripts/hygiene.py
261
279
  ```
262
280
 
263
281
  See [CONTRIBUTING.md](CONTRIBUTING.md) and the concrete good-first-issue templates in `.github/ISSUE_TEMPLATE/`.
282
+ Report a reproducible defect with the [bug form](https://github.com/aliengineering-byte/phaseprobe/issues/new?template=bug_report.yml), or propose a bounded adapter or workflow with the [feature form](https://github.com/aliengineering-byte/phaseprobe/issues/new?template=feature_request.yml). Please do not include secrets or private model data.
264
283
 
265
284
  ## License and citation
266
285
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "phaseprobe"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Find reproducible qualitative simulation transitions and turn them into regression tests."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -27,4 +27,4 @@ __all__ = [
27
27
  "run_simulation",
28
28
  ]
29
29
 
30
- __version__ = "0.2.1"
30
+ __version__ = "0.3.0"
@@ -19,7 +19,7 @@ from phaseprobe.errors import (
19
19
  NumericalFailure,
20
20
  PhaseProbeError,
21
21
  )
22
- from phaseprobe.generate import generate_regression_test
22
+ from phaseprobe.generate import generate_regression_test, verify_generated_evidence
23
23
  from phaseprobe.replay import verify_replay
24
24
  from phaseprobe.reporting import json_report, regenerate_reports, terminal_report
25
25
 
@@ -81,6 +81,12 @@ def build_parser() -> argparse.ArgumentParser:
81
81
  generate.add_argument("--output-directory", type=Path, default=Path("tests") / "generated")
82
82
  generate.add_argument("--json", action="store_true")
83
83
 
84
+ verify_evidence = commands.add_parser(
85
+ "verify-evidence", help="verify generated pytest evidence and artifacts offline"
86
+ )
87
+ verify_evidence.add_argument("evidence", type=Path)
88
+ verify_evidence.add_argument("--json", action="store_true")
89
+
84
90
  report = commands.add_parser(
85
91
  "report", help="regenerate terminal, JSON, and offline HTML evidence"
86
92
  )
@@ -172,6 +178,7 @@ def _generate_command(args: argparse.Namespace) -> int:
172
178
  "status": "PYTEST REGRESSION GENERATED",
173
179
  "test": str(generated.test_path),
174
180
  "fixture": str(generated.fixture_path),
181
+ "evidence": str(generated.evidence_path),
175
182
  }
176
183
  if bool(getattr(args, "json", False)):
177
184
  sys.stdout.write(json_report(data))
@@ -180,6 +187,24 @@ def _generate_command(args: argparse.Namespace) -> int:
180
187
  print()
181
188
  print(f"Test: {generated.test_path}")
182
189
  print(f"Replay fixture: {generated.fixture_path}")
190
+ print(f"Execution evidence: {generated.evidence_path}")
191
+ return int(ExitCode.OK)
192
+
193
+
194
+ def _verify_evidence_command(args: argparse.Namespace) -> int:
195
+ evidence = args.evidence
196
+ if not isinstance(evidence, Path):
197
+ raise ConfigurationError("evidence must be a path")
198
+ result = verify_generated_evidence(evidence)
199
+ data = result.as_dict()
200
+ if bool(getattr(args, "json", False)):
201
+ sys.stdout.write(json_report(data))
202
+ else:
203
+ print(data["status"])
204
+ print()
205
+ print(f"Evidence SHA-256: {result.evidence_sha256}")
206
+ print(f"Replay fixture: {result.fixture_path}")
207
+ print(f"Pytest regression: {result.test_path}")
183
208
  return int(ExitCode.OK)
184
209
 
185
210
 
@@ -221,6 +246,8 @@ def main(argv: Sequence[str] | None = None) -> int:
221
246
  return _replay_command(args)
222
247
  if args.command == "generate-test":
223
248
  return _generate_command(args)
249
+ if args.command == "verify-evidence":
250
+ return _verify_evidence_command(args)
224
251
  if args.command == "report":
225
252
  return _report_command(args)
226
253
  raise ConfigurationError(f"unknown command {args.command!r}")
@@ -0,0 +1,377 @@
1
+ """Safe deterministic pytest generation from validated replay fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from phaseprobe import __version__
13
+ from phaseprobe.errors import IntegrityError
14
+ from phaseprobe.replay import validate_fixture, verify_replay
15
+
16
+ MAX_EVIDENCE_BYTES = 1_048_576
17
+ MAX_GENERATED_ARTIFACT_BYTES = 33_554_432
18
+ MAX_JSON_DEPTH = 32
19
+ MAX_JSON_NODES = 10_000
20
+
21
+ _PRODUCER = {
22
+ "repository": "aliengineering-byte/phaseprobe",
23
+ "version": __version__,
24
+ "capability": "validated-replay-to-pytest",
25
+ "documentation": "https://github.com/aliengineering-byte/phaseprobe#five-minute-quick-start",
26
+ }
27
+
28
+ _LIMITATIONS = [
29
+ "Generation-time replay verifies only the fixture's declared exact or tolerance policy.",
30
+ "The generated pytest detects future mismatch; it does not prove an exact bifurcation point or global minimality.",
31
+ "Artifact hashes are unsigned and must be recomputed by a consumer; the evidence record does not authenticate itself.",
32
+ ]
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class GeneratedTest:
37
+ """Generated test, copied fixture, and machine-readable execution evidence."""
38
+
39
+ test_path: Path
40
+ fixture_path: Path
41
+ evidence_path: Path
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class GeneratedEvidenceVerification:
46
+ """Bounded offline validation result for generated regression evidence."""
47
+
48
+ evidence_path: Path
49
+ fixture_path: Path
50
+ test_path: Path
51
+ evidence_sha256: str
52
+
53
+ def as_dict(self) -> dict[str, object]:
54
+ return {
55
+ "schema_version": "1.0",
56
+ "status": "GENERATED EVIDENCE VERIFIED",
57
+ "integrity": "unsigned-recomputable",
58
+ "authentication": "none",
59
+ "evidence_sha256": self.evidence_sha256,
60
+ "artifacts": {
61
+ "replay_fixture": str(self.fixture_path),
62
+ "pytest_regression": str(self.test_path),
63
+ },
64
+ }
65
+
66
+
67
+ def _safe_name(value: object) -> str:
68
+ candidate = re.sub(r"[^a-z0-9]+", "_", str(value).lower()).strip("_")
69
+ return candidate[:64] or "model"
70
+
71
+
72
+ def _reject_conflict(path: Path, expected: bytes, description: str) -> bool:
73
+ if not path.exists():
74
+ return True
75
+ if not path.is_file() or path.read_bytes() != expected:
76
+ raise FileExistsError(
77
+ f"refusing to overwrite existing {description} {path}; "
78
+ "choose another --output-directory"
79
+ )
80
+ return False
81
+
82
+
83
+ def _sha256(data: bytes) -> str:
84
+ return hashlib.sha256(data).hexdigest()
85
+
86
+
87
+ def _classification(payload: object) -> object:
88
+ return payload.get("classification") if isinstance(payload, dict) else None
89
+
90
+
91
+ def _generated_source(model_name: str, scipy_backed: bool) -> bytes:
92
+ scipy_import = "import pytest\n" if scipy_backed else ""
93
+ scipy_guard = (
94
+ '\npytest.importorskip("scipy")\npytestmark = pytest.mark.scipy\n' if scipy_backed else ""
95
+ )
96
+ source = f'''"""Generated by PhaseProbe from a validated replay fixture."""
97
+
98
+ from pathlib import Path
99
+
100
+ {scipy_import}
101
+ from phaseprobe.replay import verify_replay
102
+ {scipy_guard}
103
+
104
+ FIXTURE = Path(__file__).parent / "fixtures" / "{model_name}-replay.json"
105
+
106
+
107
+ def test_{model_name}_transition_replays() -> None:
108
+ """Re-execute model/config/seed and verify the fixture's declared replay policy."""
109
+ result = verify_replay(FIXTURE)
110
+ assert result.ok, result.as_dict()
111
+ '''
112
+ return source.encode("utf-8")
113
+
114
+
115
+ def _strict_object(value: object, required: set[str], description: str) -> dict[str, Any]:
116
+ if not isinstance(value, dict) or set(value) != required:
117
+ raise IntegrityError(f"{description} must contain exactly: {', '.join(sorted(required))}")
118
+ return value
119
+
120
+
121
+ def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
122
+ result: dict[str, object] = {}
123
+ for key, value in pairs:
124
+ if key in result:
125
+ raise IntegrityError(f"duplicate JSON key {key!r}")
126
+ result[key] = value
127
+ return result
128
+
129
+
130
+ def _bounded_json(path: Path, maximum: int) -> tuple[bytes, dict[str, Any]]:
131
+ if not path.is_file():
132
+ raise IntegrityError(f"evidence must be a regular file: {path}")
133
+ if path.stat().st_size > maximum:
134
+ raise IntegrityError(f"evidence exceeds {maximum} bytes")
135
+ raw = path.read_bytes()
136
+ try:
137
+ text = raw.decode("utf-8")
138
+ parsed = json.loads(text, object_pairs_hook=_reject_duplicate_keys)
139
+ except (UnicodeError, json.JSONDecodeError, RecursionError) as exc:
140
+ raise IntegrityError(f"evidence is not strict UTF-8 JSON: {exc}") from exc
141
+ if not isinstance(parsed, dict):
142
+ raise IntegrityError("evidence root must be an object")
143
+ stack: list[tuple[object, int]] = [(parsed, 1)]
144
+ nodes = 0
145
+ while stack:
146
+ current, depth = stack.pop()
147
+ nodes += 1
148
+ if nodes > MAX_JSON_NODES:
149
+ raise IntegrityError(f"evidence exceeds {MAX_JSON_NODES} JSON nodes")
150
+ if depth > MAX_JSON_DEPTH:
151
+ raise IntegrityError(f"evidence exceeds JSON depth {MAX_JSON_DEPTH}")
152
+ if isinstance(current, dict):
153
+ stack.extend((item, depth + 1) for item in current.values())
154
+ elif isinstance(current, list):
155
+ stack.extend((item, depth + 1) for item in current)
156
+ return raw, parsed
157
+
158
+
159
+ def _artifact_path(base: Path, value: object, description: str) -> Path:
160
+ if not isinstance(value, str) or not value or "\\" in value:
161
+ raise IntegrityError(f"{description} path must be a non-empty POSIX relative path")
162
+ path = Path(value)
163
+ if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
164
+ raise IntegrityError(f"unsafe {description} path {value!r}")
165
+ try:
166
+ resolved = (base / path).resolve(strict=True)
167
+ resolved.relative_to(base.resolve(strict=True))
168
+ except (OSError, ValueError) as exc:
169
+ raise IntegrityError(f"{description} escapes or is missing: {value!r}") from exc
170
+ if not resolved.is_file():
171
+ raise IntegrityError(f"{description} must resolve to a regular file")
172
+ if resolved.stat().st_size > MAX_GENERATED_ARTIFACT_BYTES:
173
+ raise IntegrityError(f"{description} exceeds {MAX_GENERATED_ARTIFACT_BYTES} bytes")
174
+ return resolved
175
+
176
+
177
+ def _artifact_record(artifacts: dict[str, Any], name: str, base: Path) -> tuple[Path, bytes]:
178
+ record = _strict_object(artifacts.get(name), {"path", "sha256"}, name)
179
+ path = _artifact_path(base, record["path"], name)
180
+ raw = path.read_bytes()
181
+ expected_hash = record["sha256"]
182
+ if not isinstance(expected_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_hash):
183
+ raise IntegrityError(f"{name} sha256 must be 64 lowercase hex characters")
184
+ if _sha256(raw) != expected_hash:
185
+ raise IntegrityError(f"{name} sha256 mismatch")
186
+ return path, raw
187
+
188
+
189
+ def verify_generated_evidence(evidence_path: Path) -> GeneratedEvidenceVerification:
190
+ """Verify bounded generated-test evidence and derive its important claims offline."""
191
+
192
+ evidence_raw, evidence = _bounded_json(evidence_path, MAX_EVIDENCE_BYTES)
193
+ evidence = _strict_object(
194
+ evidence,
195
+ {
196
+ "schema_version",
197
+ "producer",
198
+ "claim",
199
+ "decision",
200
+ "artifacts",
201
+ "reproduction",
202
+ "limitations",
203
+ },
204
+ "evidence",
205
+ )
206
+ if evidence["schema_version"] != "1.0":
207
+ raise IntegrityError("unsupported generated-evidence schema_version")
208
+ if _strict_object(evidence["producer"], set(_PRODUCER), "producer") != _PRODUCER:
209
+ raise IntegrityError("producer identity does not match this PhaseProbe release")
210
+ claim = _strict_object(
211
+ evidence["claim"],
212
+ {
213
+ "kind",
214
+ "model",
215
+ "baseline_classification",
216
+ "changed_classification",
217
+ "reproducible_at_generation",
218
+ },
219
+ "claim",
220
+ )
221
+ decision = _strict_object(
222
+ evidence["decision"], {"status", "comparison_mode", "comparisons"}, "decision"
223
+ )
224
+ artifacts = _strict_object(
225
+ evidence["artifacts"], {"replay_fixture", "pytest_regression"}, "artifacts"
226
+ )
227
+ reproduction = _strict_object(
228
+ evidence["reproduction"], {"working_directory", "command"}, "reproduction"
229
+ )
230
+ if evidence["limitations"] != _LIMITATIONS:
231
+ raise IntegrityError("limitations do not match the generated-evidence contract")
232
+
233
+ base = evidence_path.resolve(strict=True).parent
234
+ fixture_path, fixture_raw = _artifact_record(artifacts, "replay_fixture", base)
235
+ test_path, test_raw = _artifact_record(artifacts, "pytest_regression", base)
236
+ fixture = validate_fixture(fixture_path)
237
+ replay = verify_replay(fixture_path)
238
+ if not replay.ok:
239
+ raise IntegrityError("replay fixture no longer satisfies its declared policy")
240
+ model_name = _safe_name(fixture.get("model"))
241
+ if artifacts["replay_fixture"]["path"] != f"fixtures/{model_name}-replay.json":
242
+ raise IntegrityError("replay fixture path conflicts with the derived model name")
243
+ if artifacts["pytest_regression"]["path"] != f"test_{model_name}_transition.py":
244
+ raise IntegrityError("pytest path conflicts with the derived model name")
245
+ baseline = fixture.get("baseline")
246
+ metadata = baseline.get("execution_metadata") if isinstance(baseline, dict) else None
247
+ adapter_configuration = (
248
+ metadata.get("adapter_configuration") if isinstance(metadata, dict) else None
249
+ )
250
+ scipy_backed = (
251
+ isinstance(adapter_configuration, dict)
252
+ and adapter_configuration.get("adapter") == "scipy.solve_ivp"
253
+ )
254
+ if test_raw != _generated_source(model_name, scipy_backed):
255
+ raise IntegrityError("pytest regression is not the fixed template derived from the fixture")
256
+ baseline_classification = _classification(fixture.get("baseline"))
257
+ changed_classification = _classification(fixture.get("changed"))
258
+ expected_kind = (
259
+ "qualitative-simulation-regression"
260
+ if baseline_classification is not None
261
+ and changed_classification is not None
262
+ and baseline_classification != changed_classification
263
+ else "simulation-replay-regression"
264
+ )
265
+ if claim != {
266
+ "kind": expected_kind,
267
+ "model": fixture.get("model"),
268
+ "baseline_classification": baseline_classification,
269
+ "changed_classification": changed_classification,
270
+ "reproducible_at_generation": fixture.get("reproducible"),
271
+ }:
272
+ raise IntegrityError("claim conflicts with the replay fixture")
273
+ if decision != {
274
+ "status": "REPLAY_VERIFIED",
275
+ "comparison_mode": replay.mode,
276
+ "comparisons": [dict(item) for item in replay.comparisons],
277
+ }:
278
+ raise IntegrityError("decision conflicts with replay verification")
279
+ if reproduction != {
280
+ "working_directory": ".",
281
+ "command": f"python -m pytest -q {test_path.name}",
282
+ }:
283
+ raise IntegrityError("reproduction command conflicts with the generated artifact")
284
+ if _sha256(fixture_raw) != artifacts["replay_fixture"]["sha256"]:
285
+ raise IntegrityError("replay fixture changed during verification")
286
+ return GeneratedEvidenceVerification(
287
+ evidence_path=evidence_path.resolve(strict=True),
288
+ fixture_path=fixture_path,
289
+ test_path=test_path,
290
+ evidence_sha256=_sha256(evidence_raw),
291
+ )
292
+
293
+
294
+ def generate_regression_test(fixture: Path, output_directory: Path) -> GeneratedTest:
295
+ """Validate evidence, copy its fixture, and emit a non-extensible pytest template."""
296
+
297
+ payload = validate_fixture(fixture)
298
+ verification = verify_replay(fixture)
299
+ if not verification.ok:
300
+ raise ValueError(
301
+ "replay fixture does not reproduce; refusing to generate a regression test"
302
+ )
303
+ model_name = _safe_name(payload.get("model"))
304
+ fixture_directory = output_directory / "fixtures"
305
+ copied_fixture = fixture_directory / f"{model_name}-replay.json"
306
+ test_path = output_directory / f"test_{model_name}_transition.py"
307
+ evidence_path = output_directory / f"{model_name}-pytest-evidence.json"
308
+ baseline = payload.get("baseline")
309
+ metadata = baseline.get("execution_metadata") if isinstance(baseline, dict) else None
310
+ adapter_configuration = (
311
+ metadata.get("adapter_configuration") if isinstance(metadata, dict) else None
312
+ )
313
+ scipy_backed = (
314
+ isinstance(adapter_configuration, dict)
315
+ and adapter_configuration.get("adapter") == "scipy.solve_ivp"
316
+ )
317
+ fixture_bytes = fixture.read_bytes()
318
+ source_bytes = _generated_source(model_name, scipy_backed)
319
+ baseline_classification = _classification(payload.get("baseline"))
320
+ changed_classification = _classification(payload.get("changed"))
321
+ claim_kind = (
322
+ "qualitative-simulation-regression"
323
+ if baseline_classification is not None
324
+ and changed_classification is not None
325
+ and baseline_classification != changed_classification
326
+ else "simulation-replay-regression"
327
+ )
328
+ evidence = {
329
+ "schema_version": "1.0",
330
+ "producer": _PRODUCER,
331
+ "claim": {
332
+ "kind": claim_kind,
333
+ "model": payload.get("model"),
334
+ "baseline_classification": baseline_classification,
335
+ "changed_classification": changed_classification,
336
+ "reproducible_at_generation": payload.get("reproducible"),
337
+ },
338
+ "decision": {
339
+ "status": "REPLAY_VERIFIED",
340
+ "comparison_mode": verification.mode,
341
+ "comparisons": [dict(item) for item in verification.comparisons],
342
+ },
343
+ "artifacts": {
344
+ "replay_fixture": {
345
+ "path": copied_fixture.relative_to(output_directory).as_posix(),
346
+ "sha256": _sha256(fixture_bytes),
347
+ },
348
+ "pytest_regression": {
349
+ "path": test_path.relative_to(output_directory).as_posix(),
350
+ "sha256": _sha256(source_bytes),
351
+ },
352
+ },
353
+ "reproduction": {
354
+ "working_directory": ".",
355
+ "command": f"python -m pytest -q {test_path.name}",
356
+ },
357
+ "limitations": _LIMITATIONS,
358
+ }
359
+ evidence_bytes = (
360
+ json.dumps(evidence, allow_nan=False, indent=2, sort_keys=True) + "\n"
361
+ ).encode("utf-8")
362
+ write_fixture = _reject_conflict(copied_fixture, fixture_bytes, "replay fixture")
363
+ write_test = _reject_conflict(test_path, source_bytes, "generated pytest")
364
+ write_evidence = _reject_conflict(evidence_path, evidence_bytes, "generated pytest evidence")
365
+ output_directory.mkdir(parents=True, exist_ok=True)
366
+ fixture_directory.mkdir(exist_ok=True)
367
+ if write_fixture:
368
+ copied_fixture.write_bytes(fixture_bytes)
369
+ if write_test:
370
+ test_path.write_bytes(source_bytes)
371
+ if write_evidence:
372
+ evidence_path.write_bytes(evidence_bytes)
373
+ return GeneratedTest(
374
+ test_path=test_path,
375
+ fixture_path=copied_fixture,
376
+ evidence_path=evidence_path,
377
+ )