diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52fa332..d07e213 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,8 +50,8 @@ jobs: - run: python -m ruff check . - run: python -m mypy src tests - run: python -m pytest --cov=phaseprobe --cov-report=term - - run: python -m phaseprobe perturb --config examples/scipy/lorenz-negative.json - - run: python -m phaseprobe check --config examples/scipy/predator-prey.json + - run: python -m phaseprobe perturb --example scipy-lorenz-negative + - run: python -m phaseprobe check --example scipy-predator-prey package-and-hygiene: runs-on: ubuntu-latest @@ -65,14 +65,10 @@ jobs: - run: python -m build - run: python -m twine check dist/* - run: python scripts/audit_package.py - - run: python -m venv .cache/packed-smoke - - run: .cache/packed-smoke/bin/python -m pip install dist/*.whl - - run: .cache/packed-smoke/bin/python -m phaseprobe --version - - run: .cache/packed-smoke/bin/python -m phaseprobe scan --example logistic-negative - - run: .cache/packed-smoke/bin/python -c "import importlib.util; assert importlib.util.find_spec('numpy') is None; assert importlib.util.find_spec('scipy') is None" - - run: python -m venv .cache/scipy-smoke - - run: .cache/scipy-smoke/bin/python -m pip install "$(find dist -name '*.whl')[scipy]" - - run: .cache/scipy-smoke/bin/python -m phaseprobe check --config examples/scipy/predator-prey.json + - run: >- + python scripts/verify_artifacts.py + --dist-dir dist + --work-root "${RUNNER_TEMP}/phaseprobe artifact gate" - run: python scripts/check_links.py - run: python scripts/hygiene.py - uses: actions/upload-artifact@v4 @@ -80,3 +76,21 @@ jobs: name: phaseprobe-packages path: dist/* if-no-files-found: error + + windows-artifact-install: + name: artifact install / windows / Python 3.12 + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --upgrade pip build twine + - run: python -m build + - run: python -m twine check dist/* + - run: python scripts/audit_package.py + - run: >- + python scripts/verify_artifacts.py + --dist-dir dist + --work-root "$env:RUNNER_TEMP\phaseprobe artifact gate" diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 9b8f0bb..e1b350c 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -4,11 +4,11 @@ on: workflow_dispatch: inputs: tag: - description: Immutable release tag (for example, v0.2.0) + description: Immutable release tag (for example, v0.2.1) required: true type: string version: - description: Expected package version (for example, 0.2.0) + description: Expected package version (for example, 0.2.1) required: true type: string commit: @@ -90,13 +90,15 @@ jobs: expected_version, wheel_path, sdist_path = sys.argv[1:] with zipfile.ZipFile(wheel_path) as archive: + wheel_names = set(archive.namelist()) metadata_names = [ - name for name in archive.namelist() if name.endswith(".dist-info/METADATA") + name for name in wheel_names if name.endswith(".dist-info/METADATA") ] assert len(metadata_names) == 1, metadata_names wheel_metadata = email.message_from_bytes(archive.read(metadata_names[0])) with tarfile.open(sdist_path, "r:gz") as archive: + sdist_names = {member.name for member in archive.getmembers()} metadata_members = [ member for member in archive.getmembers() @@ -107,6 +109,20 @@ jobs: assert extracted is not None sdist_metadata = email.message_from_bytes(extracted.read()) + sdist_roots = {name.split("/", 1)[0] for name in sdist_names if "/" in name} + assert len(sdist_roots) == 1, sorted(sdist_roots) + sdist_root = next(iter(sdist_roots)) + source_prefix = f"{sdist_root}/src/phaseprobe/" + runtime_files = { + name.removeprefix(f"{sdist_root}/src/") + for name in sdist_names + if name.startswith(source_prefix) + and not name.endswith(".py") + and "__pycache__" not in name.split("/") + } + assert "phaseprobe/py.typed" in runtime_files + assert not runtime_files - wheel_names, sorted(runtime_files - wheel_names) + for metadata in (wheel_metadata, sdist_metadata): assert metadata["Name"] == "phaseprobe" assert metadata["Version"] == expected_version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9bd329..a9f0c80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,13 +20,10 @@ jobs: - run: python -m build - run: python -m twine check dist/* - run: python scripts/audit_package.py - - run: python -m venv .cache/tag-core-smoke - - run: .cache/tag-core-smoke/bin/python -m pip install dist/*.whl - - run: .cache/tag-core-smoke/bin/python -m phaseprobe --version - - run: .cache/tag-core-smoke/bin/python -m phaseprobe scan --example logistic - - run: python -m venv .cache/tag-scipy-smoke - - run: .cache/tag-scipy-smoke/bin/python -m pip install "$(find dist -name '*.whl')[scipy]" - - run: .cache/tag-scipy-smoke/bin/python -m phaseprobe check --config examples/scipy/predator-prey.json + - run: >- + python scripts/verify_artifacts.py + --dist-dir dist + --work-root "${RUNNER_TEMP}/phaseprobe tagged artifact gate" - run: python scripts/hygiene.py - uses: actions/upload-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6140c59..b0d6437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes are documented here. PhaseProbe follows semantic versioning. +## 0.2.1 — 2026-08-31 + +- Fixed Issue #4: all four SciPy quick-start configurations now ship inside the importable + package in both wheel and source distribution and load through `importlib.resources` as the + `scipy-lorenz`, `scipy-lorenz-negative`, `scipy-predator-prey`, and + `scipy-predator-prey-coarse` built-in examples. +- Preserved the four exact former `examples/scipy/` config paths as narrow compatibility aliases + when no file exists at the requested path. Existing user files take precedence; matching is + case-sensitive and separator-portable, with no fuzzy or basename fallback. +- Added actionable, versioned diagnostics for missing or malformed built-in resources without + changing arbitrary config-path or built-in-example behavior. +- Added Linux and Windows Python 3.12 release gates that build, inspect, install, and exercise the + wheel and sdist outside the checkout with `PYTHONPATH` removed. The gate runs scan, replay, + generated pytest, SciPy Lorenz, SciPy predator–prey, `pip check`, and a dependency-free base + wheel check. +- Added a source-derived runtime-resource audit and the PEP 561 `py.typed` marker. +- Generated regression creation is now idempotent for identical evidence and rejects conflicting + files instead of silently overwriting them. +- Added an analytic backward-time directional-event check while preserving all solver defaults + and the existing tolerance-based adaptive replay contract. + ## 0.2.0 — 2026-08-02 - Added the backward-compatible `TrajectoryAdapter` protocol and shared engine dispatch; all v0.1 diff --git a/CITATION.cff b/CITATION.cff index ae458a2..d221e29 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -4,8 +4,8 @@ title: "PhaseProbe" type: software authors: - name: "Ali" -version: 0.2.0 -date-released: 2026-08-02 +version: 0.2.1 +date-released: 2026-08-31 url: "https://github.com/aliengineering-byte/phaseprobe" repository-code: "https://github.com/aliengineering-byte/phaseprobe" license: Apache-2.0 diff --git a/README.md b/README.md index 105a2de..d92fd8d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Maintaining a simulation is risky when a tiny parameter or initial-condition change can cross a qualitative boundary while ordinary numeric assertions still look plausible. PhaseProbe runs a bounded, deterministic search, records exactly what it tested, and emits an offline report plus an executable pytest regression. ```console -$ pip install phaseprobe +$ pip install phaseprobe pytest $ phaseprobe scan --example logistic QUALITATIVE TRANSITION FOUND @@ -29,6 +29,7 @@ No API key, LLM, GPU, Docker, account, telemetry, network connection, or hosted ```bash pip install phaseprobe pip install "phaseprobe[scipy]" +pip install pytest # needed only to execute a generated regression test ``` ## Five-minute quick start @@ -36,7 +37,7 @@ pip install "phaseprobe[scipy]" Requires Python 3.10 or newer on Windows or Linux. ```bash -pip install phaseprobe +pip install phaseprobe pytest phaseprobe scan --example logistic phaseprobe replay .phaseprobe/runs//replay.json phaseprobe generate-test .phaseprobe/runs//replay.json @@ -59,22 +60,41 @@ Five-minute path: [![SciPy demo: solve_ivp evidence, tolerance replay, and generated pytest](assets/scipy-demo-static.png)](assets/scipy-demo.gif) The verified command transcript is [assets/scipy-demo-session.txt](assets/scipy-demo-session.txt), -with a self-contained [HTML example report](examples/scipy/report.html). +with a self-contained [HTML example report](examples/scipy/report.html). The following path starts +from a clean environment and does not require a source checkout: ```bash -python -m pip install "phaseprobe[scipy]" -phaseprobe perturb --config examples/scipy/lorenz.json -phaseprobe check --config examples/scipy/predator-prey.json +python -m venv .venv +# Linux: source .venv/bin/activate +# Windows PowerShell: .venv\Scripts\Activate.ps1 +python -m pip install "phaseprobe[scipy]" "pytest==8.4.1" +phaseprobe perturb --example scipy-lorenz +phaseprobe check --example scipy-predator-prey phaseprobe replay .phaseprobe/runs//replay.json phaseprobe generate-test .phaseprobe/runs//replay.json python -m pytest -q tests/generated ``` +PhaseProbe 0.2.1 also recognizes the former Issue #4 command +`phaseprobe perturb --config examples/scipy/lorenz.json` when that relative file is absent and +loads the corresponding packaged example. This compatibility is limited to the four former +`examples/scipy/` paths: an existing file always takes precedence, matching is case-sensitive, +both slash styles are accepted, and unrelated missing paths remain errors. New documentation and +automation should use the installed-safe `--example scipy-*` names. + The Lorenz command searches a declared initial-`x` perturbation and reports only finite-time -divergence evidence. `examples/scipy/lorenz-negative.json` is its short-window negative control. -The predator–prey command checks the declared first integral with tightly resolved DOP853 settings; -`examples/scipy/predator-prey-coarse.json` deliberately fails the same policy with loose RK23 -settings. +divergence evidence and prints `FINITE-TIME TRAJECTORY DIVERGENCE FOUND`. Its installed +short-window control is `--example scipy-lorenz-negative`. The predator–prey command checks the +declared first integral with tightly resolved DOP853 settings and prints `CHECK POLICY PASSED`; +`--example scipy-predator-prey-coarse` deliberately fails the same policy with loose RK23 settings. +Each successful command writes a replay fixture and offline report below +`.phaseprobe/runs//`. + +Run `phaseprobe perturb --help` or `phaseprobe check --help` to list installed example names. A +missing packaged resource reports the PhaseProbe version and resource name. Adaptive SciPy replay +compares the declared state, observable, invariant, endpoint, event, and retained-grid +tolerances—it does not promise bitwise trajectory equality across platforms or dependency +versions. For a direct Python API: @@ -113,7 +133,7 @@ does not import it. See [the audited contract](docs/SCIPY_SOLVE_IVP_AUDIT.md), | `perturb` | Baseline/perturbed twin runs over bounded initial-state changes | Finding or no finding, exit `0` | | `check` | Execute a declared configuration policy for CI | Exit `1` only when policy fails | | `replay` | Validate fixture integrity and re-execute model, parameters, seed, initial state, tolerances, and retention | Declared `exact` or `tolerance` comparison passes | -| `generate-test` | Validate and copy a fixture into a non-extensible pytest template | Executable test under `tests/generated/` | +| `generate-test` | Validate and copy a fixture into a non-extensible pytest template without conflicting overwrites | Executable test under `tests/generated/` | | `report` | Regenerate terminal, versioned JSON, and self-contained offline HTML evidence | Local report files | Common options: @@ -121,6 +141,7 @@ Common options: ```console phaseprobe scan --config examples/configs/logistic-scan.json phaseprobe perturb --example lorenz --json +phaseprobe perturb --example scipy-lorenz --json phaseprobe check --example predator-prey phaseprobe scan --example logistic --fail-on-finding ``` @@ -135,6 +156,8 @@ Exit codes are stable: `0` completed, `1` declared policy or explicit `--fail-on | Lorenz system | `phaseprobe perturb --example lorenz` | Small initial separation exceeds the declared finite-time trajectory-distance threshold | Short window plus unreachable threshold reports no finding | [Lorenz, 1963](https://journals.ametsoc.org/view/journals/atsc/20/2/1520-0469_1963_020_0130_dnf_2_0_co_2.xml) | | Predator–prey | `phaseprobe check --example predator-prey` | Refined RK4 step preserves the analytic first integral within tolerance | Coarse step fails the invariant-drift policy | [Lotka, 1920](https://doi.org/10.1073/pnas.6.7.410) | | Genetic toggle | `phaseprobe perturb --example toggle` | Bounded initial-state perturbation reaches the opposite dominant state | Smaller declared range stays in the baseline basin | [Gardner, Cantor & Collins, 2000](https://www.nature.com/articles/35002131) | +| SciPy Lorenz | `phaseprobe perturb --example scipy-lorenz` | DOP853 twin trajectories cross the declared finite-time distance threshold | `--example scipy-lorenz-negative` shortens the window | [Lorenz, 1963](https://journals.ametsoc.org/view/journals/atsc/20/2/1520-0469_1963_020_0130_dnf_2_0_co_2.xml) | +| SciPy predator–prey | `phaseprobe check --example scipy-predator-prey` | Tight DOP853 settings preserve the declared first integral tolerance | `--example scipy-predator-prey-coarse` deliberately fails | [Lotka, 1920](https://doi.org/10.1073/pnas.6.7.410) | Each configuration records the seed, fixed integration/iteration settings, tolerances, burn-in, observation window, classification rule, refinement rule, invalid-state policy, and trace cap. See [examples/README.md](examples/README.md) for equations and interpretation. diff --git a/RELEASE_NOTES_0.2.1.md b/RELEASE_NOTES_0.2.1.md new file mode 100644 index 0000000..4afc9f8 --- /dev/null +++ b/RELEASE_NOTES_0.2.1.md @@ -0,0 +1,69 @@ +# PhaseProbe 0.2.1 release notes + +Status: prepared locally on 2026-08-31; not published. + +## Result + +PhaseProbe 0.2.1 fixes GitHub Issue #4. The four SciPy quick-start configurations are runtime +resources inside the `phaseprobe` package, are present in both wheel and sdist, and load through +`importlib.resources` with installed-safe `--example scipy-*` names. + +The exact former relative config paths also remain compatible. PhaseProbe first reads an existing +file; only when it is absent does one of the four case-sensitive `examples/scipy/` paths resolve to +its matching packaged built-in. Both slash styles are accepted. Arbitrary missing paths, basename +matches, and case variants remain errors. The reporter's original command therefore succeeds from +an unrelated working directory after installation: + +```text +phaseprobe perturb --config examples/scipy/lorenz.json +``` + +The reporter-confirmed scan, replay, generated pytest, Lorenz, and predator–prey behavior remains +intact. This patch does not change solver methods, tolerances, thresholds, search bounds, replay +semantics, or scientific claims. + +## Root cause + +Version 0.2.0 documented `examples/scipy/*.json` paths that existed only in the source checkout. +Hatchling included the top-level `examples/` tree in the sdist as source material but built wheels +only from `src/phaseprobe`; consequently neither the published wheel nor a wheel built while +installing the sdist contained those configurations at runtime. Editable/source tests and CI ran +from the repository root, where the relative paths existed, masking the release defect. + +## Changes + +- Added narrow compatibility for the four former checkout-relative SciPy paths while preserving + existing-file precedence and rejecting fuzzy matches. +- Added packaged SciPy Lorenz and predator–prey configurations plus positive/negative controls. +- Added missing/malformed built-in-resource diagnostics with package version and valid choices. +- Added source-derived wheel/sdist resource inventory checks and `py.typed`. +- Added clean wheel, clean sdist, and dependency-free base-wheel smoke verification outside the + checkout with `PYTHONPATH` removed on Linux and Windows Python 3.12. +- The installed smoke runs scan, replay, generated pytest, Lorenz, predator–prey, and `pip check`. +- Generated regressions are idempotent for identical evidence and reject conflicting overwrites. +- Added an analytic backward-time directional event check for the SciPy adapter. + +## Verification commands + +```text +python -m ruff format --check . +python -m ruff check . +python -m mypy src tests +python -m pytest --cov=phaseprobe --cov-report=term-missing +python -m build +python -m twine check dist/* +python scripts/audit_package.py +python scripts/verify_artifacts.py --dist-dir dist --work-root +python scripts/check_links.py +python scripts/hygiene.py +``` + +## Artifact provenance + +Local review builds produce `dist/phaseprobe-0.2.1-py3-none-any.whl` and +`dist/phaseprobe-0.2.1.tar.gz`. Their SHA-256 values identify only those exact local files; they are +not permanent release hashes. The trusted publishing workflow rebuilds artifacts from the final +tag, so published hashes must be recorded from that separately authorized run. + +PhaseProbe 0.2.1 is not yet published. These notes do not assert that remote CI has passed. No +merge, PyPI upload, GitHub release, tag, or Issue #4 closure is part of this pull request. diff --git a/SCIENTIFIC_METHODS.md b/SCIENTIFIC_METHODS.md index 34553c6..a86a0d2 100644 --- a/SCIENTIFIC_METHODS.md +++ b/SCIENTIFIC_METHODS.md @@ -19,6 +19,9 @@ Tolerance replay compares retained times/states, named numeric observables, clas invariant results and stored thresholds, endpoint/event evidence, and expected solver success. Environment and solver evidence are retained for interpretation. Artifact hashes still detect fixture tampering, but an adaptive trace hash is not required to match across supported platforms. +The adapter validation suite checks an exponential system with an analytic terminal crossing in +both forward and backward integration directions; this validates event plumbing, not exhaustive +root detection between adaptive internal steps. See [the complete SciPy audit](docs/SCIPY_SOLVE_IVP_AUDIT.md). PhaseProbe 0.1.0 produces bounded computational evidence. It does not perform formal verification, model validation against observations, or computer-assisted proof. diff --git a/assets/scipy-demo-session.txt b/assets/scipy-demo-session.txt index 13486c1..8cafcc9 100644 --- a/assets/scipy-demo-session.txt +++ b/assets/scipy-demo-session.txt @@ -1,4 +1,4 @@ -$ phaseprobe perturb --config examples/scipy/lorenz.json +$ phaseprobe perturb --example scipy-lorenz FINITE-TIME TRAJECTORY DIVERGENCE FOUND Model: lorenz-scipy @@ -10,12 +10,12 @@ Baseline regime: bounded-finite-window Changed regime: bounded-finite-window Invariant violations: 0 Repeatable: true -Replay: /.cache/phaseprobe-scipy-demo-uqyijm6y/runs/lorenz/20260802T175927Z-786e1809/replay.json +Replay: /.cache/phaseprobe-scipy-demo-4sai_vv1/runs/lorenz/20260831T174342Z-7fcc5f8d/replay.json Scope: Smallest reproducible perturbation found within the declared finite search; not a proof of global minimality. -HTML report: /.cache/phaseprobe-scipy-demo-uqyijm6y/runs/lorenz/20260802T175927Z-786e1809/report.html +HTML report: /.cache/phaseprobe-scipy-demo-4sai_vv1/runs/lorenz/20260831T174342Z-7fcc5f8d/report.html -$ phaseprobe check --config examples/scipy/predator-prey.json +$ phaseprobe check --example scipy-predator-prey CHECK POLICY PASSED Model: predator-prey-scipy @@ -23,8 +23,8 @@ Baseline regime: positive-oscillation Changed regime: n/a Invariant violations: 0 Repeatable: true -Replay: /.cache/phaseprobe-scipy-demo-uqyijm6y/runs/predator-prey/20260802T175928Z-073e812d/replay.json -HTML report: /.cache/phaseprobe-scipy-demo-uqyijm6y/runs/predator-prey/20260802T175928Z-073e812d/report.html +Replay: /.cache/phaseprobe-scipy-demo-4sai_vv1/runs/predator-prey/20260831T174343Z-38b5e8de/replay.json +HTML report: /.cache/phaseprobe-scipy-demo-4sai_vv1/runs/predator-prey/20260831T174343Z-38b5e8de/report.html $ phaseprobe replay .phaseprobe/runs//replay.json REPLAY VERIFIED @@ -36,11 +36,11 @@ baseline: declared tolerances=True $ phaseprobe generate-test .phaseprobe/runs//replay.json PYTEST REGRESSION GENERATED -Test: /.cache/phaseprobe-scipy-demo-uqyijm6y/generated/test_predator_prey_scipy_transition.py -Replay fixture: /.cache/phaseprobe-scipy-demo-uqyijm6y/generated/fixtures/predator_prey_scipy-replay.json +Test: /.cache/phaseprobe-scipy-demo-4sai_vv1/generated/test_predator_prey_scipy_transition.py +Replay fixture: /.cache/phaseprobe-scipy-demo-4sai_vv1/generated/fixtures/predator_prey_scipy-replay.json $ python -m pytest -q tests/generated . [100%] -1 passed in 1.04s +1 passed in 1.08s -Measured demo run: lorenz=5.916s, predator-prey=1.397s, replay=1.343s, generate=1.300s, pytest=1.759s. +Measured demo run: lorenz=6.285s, predator-prey=1.409s, replay=1.336s, generate=1.340s, pytest=1.814s. diff --git a/assets/scipy-demo-static.png b/assets/scipy-demo-static.png index 28b6bdf..6ef4595 100644 Binary files a/assets/scipy-demo-static.png and b/assets/scipy-demo-static.png differ diff --git a/assets/scipy-demo.gif b/assets/scipy-demo.gif index ab82ea0..223ebff 100644 Binary files a/assets/scipy-demo.gif and b/assets/scipy-demo.gif differ diff --git a/docs/upstream/SCIPY_INTEGRATION_PROPOSAL.md b/docs/upstream/SCIPY_INTEGRATION_PROPOSAL.md index f694941..badfa6b 100644 --- a/docs/upstream/SCIPY_INTEGRATION_PROPOSAL.md +++ b/docs/upstream/SCIPY_INTEGRATION_PROPOSAL.md @@ -44,8 +44,8 @@ plus solver configuration metadata; it does not hash or serialize callable sourc ```bash python -m pip install "phaseprobe[scipy]" -phaseprobe perturb --config examples/scipy/lorenz.json -phaseprobe check --config examples/scipy/predator-prey.json +phaseprobe perturb --example scipy-lorenz +phaseprobe check --example scipy-predator-prey phaseprobe replay .phaseprobe/runs//replay.json phaseprobe generate-test .phaseprobe/runs//replay.json ``` @@ -129,6 +129,9 @@ https://pypi.org/project/phaseprobe/ Technical proposal and limitations: https://github.com/aliengineering-byte/phaseprobe/blob/v0.2.0/docs/upstream/SCIPY_INTEGRATION_PROPOSAL.md +The release links above preserve the original 0.2.0 integration record. The installed-safe +`--example scipy-*` commands shown here are included in the planned 0.2.1 packaging fix. + I am not proposing to add PhaseProbe to SciPy core. I would value feedback on three points: 1. Whether the tolerance-based replay evidence for adaptive `solve_ivp` trajectories is diff --git a/examples/README.md b/examples/README.md index 5e3b368..7ccf495 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,16 +2,21 @@ The original eight examples use dependency-free fixed-step adapters and remain unchanged. The `scipy/` directory adds genuine public `scipy.integrate.solve_ivp` trajectories through the -optional extra: +optional extra. Installed packages use the built-in names and do not require this source directory: ```bash python -m pip install "phaseprobe[scipy]" -phaseprobe perturb --config examples/scipy/lorenz.json -phaseprobe perturb --config examples/scipy/lorenz-negative.json -phaseprobe check --config examples/scipy/predator-prey.json -phaseprobe check --config examples/scipy/predator-prey-coarse.json +phaseprobe perturb --example scipy-lorenz +phaseprobe perturb --example scipy-lorenz-negative +phaseprobe check --example scipy-predator-prey +phaseprobe check --example scipy-predator-prey-coarse ``` +The four exact `--config examples/scipy/.json` forms in the table remain usable from an +installed package: if the selected file exists, PhaseProbe reads it; otherwise the former relative +path resolves to its matching packaged example. Matching is case-sensitive and does not fall back +by basename. The `--example scipy-*` forms are preferred for new automation. + | SciPy configuration | declared evidence | expected exit | | --- | --- | --- | | `lorenz.json` | finite-time twin-trajectory distance crosses the declared threshold | 0 | diff --git a/pyproject.toml b/pyproject.toml index 4773d0d..1ab2d50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "phaseprobe" -version = "0.2.0" +version = "0.2.1" description = "Find reproducible qualitative simulation transitions and turn them into regression tests." readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/audit_package.py b/scripts/audit_package.py index 9a03889..d2a8da9 100644 --- a/scripts/audit_package.py +++ b/scripts/audit_package.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import hashlib import json import tarfile @@ -10,6 +11,17 @@ ROOT = Path(__file__).resolve().parents[1] DIST = ROOT / "dist" +SOURCE_PACKAGE = ROOT / "src" / "phaseprobe" + + +def expected_runtime_files() -> set[str]: + """Return every non-Python file that must survive both build targets.""" + + return { + path.relative_to(ROOT / "src").as_posix() + for path in SOURCE_PACKAGE.rglob("*") + if path.is_file() and path.suffix != ".py" and "__pycache__" not in path.parts + } def sha256(path: Path) -> str: @@ -20,9 +32,9 @@ def sha256(path: Path) -> str: return digest.hexdigest() -def main() -> int: - wheels = sorted(DIST.glob("phaseprobe-*.whl")) - sdists = sorted(DIST.glob("phaseprobe-*.tar.gz")) +def main(dist: Path = DIST) -> int: + wheels = sorted(dist.glob("phaseprobe-*.whl")) + sdists = sorted(dist.glob("phaseprobe-*.tar.gz")) issues: list[str] = [] if len(wheels) != 1 or len(sdists) != 1: issues.append("dist must contain exactly one PhaseProbe wheel and one source archive") @@ -40,6 +52,10 @@ def main() -> int: else: metadata = archive.read(metadata_names[0]).decode("utf-8") wheel_members = len(names) + runtime_files = expected_runtime_files() + missing_wheel_runtime = sorted(runtime_files.difference(names)) + if missing_wheel_runtime: + issues.append(f"wheel is missing runtime files: {missing_wheel_runtime}") requirements = [ line.removeprefix("Requires-Dist: ") for line in metadata.splitlines() @@ -57,6 +73,16 @@ def main() -> int: issues.append("scipy extra does not declare SciPy") with tarfile.open(sdist, "r:gz") as archive: source_names = archive.getnames() + source_roots = {name.split("/", 1)[0] for name in source_names if "/" in name} + if len(source_roots) != 1: + issues.append(f"sdist must contain one top-level directory: {sorted(source_roots)}") + missing_sdist_runtime = sorted(runtime_files) + else: + source_root = next(iter(source_roots)) + expected_sdist_runtime = {f"{source_root}/src/{name}" for name in runtime_files} + missing_sdist_runtime = sorted(expected_sdist_runtime.difference(source_names)) + if missing_sdist_runtime: + issues.append(f"sdist is missing package runtime files: {missing_sdist_runtime}") forbidden = [ name for name in source_names @@ -69,6 +95,9 @@ def main() -> int: "schema_version": "1.0", "status": "PASS" if not issues else "FAIL", "base_runtime_dependencies": unconditional, + "expected_runtime_files": sorted(runtime_files), + "missing_wheel_runtime_files": missing_wheel_runtime, + "missing_sdist_runtime_files": missing_sdist_runtime, "scipy_extra_requirements": scipy_requirements, "wheel": { "name": wheel.name, @@ -89,4 +118,7 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + parser = argparse.ArgumentParser() + parser.add_argument("--dist-dir", type=Path, default=DIST) + arguments = parser.parse_args() + raise SystemExit(main(arguments.dist_dir.resolve())) diff --git a/scripts/generate_scipy_demo.py b/scripts/generate_scipy_demo.py index 548b5eb..9f72ea0 100644 --- a/scripts/generate_scipy_demo.py +++ b/scripts/generate_scipy_demo.py @@ -85,8 +85,8 @@ def main() -> int: "-m", "phaseprobe", "perturb", - "--config", - str(EXAMPLES / "lorenz.json"), + "--example", + "scipy-lorenz", "--output-root", str(output_root / "lorenz"), ], @@ -98,8 +98,8 @@ def main() -> int: "-m", "phaseprobe", "check", - "--config", - str(EXAMPLES / "predator-prey.json"), + "--example", + "scipy-predator-prey", "--output-root", str(output_root / "predator-prey"), ], @@ -130,8 +130,8 @@ def main() -> int: ) transcript = "\n\n".join( ( - "$ phaseprobe perturb --config examples/scipy/lorenz.json\n" + lorenz_output, - "$ phaseprobe check --config examples/scipy/predator-prey.json\n" + predator_output, + "$ phaseprobe perturb --example scipy-lorenz\n" + lorenz_output, + "$ phaseprobe check --example scipy-predator-prey\n" + predator_output, "$ phaseprobe replay .phaseprobe/runs//replay.json\n" + replay_output, "$ phaseprobe generate-test .phaseprobe/runs//replay.json\n" + generate_output, @@ -156,13 +156,13 @@ def main() -> int: shutil.copyfile(predator_run / "report.html", EXAMPLES / "report.html") selected = [ - "$ phaseprobe perturb --config examples/scipy/lorenz.json", + "$ phaseprobe perturb --example scipy-lorenz", "FINITE-TIME TRAJECTORY DIVERGENCE FOUND", "Evidence: finite-time-divergence", "Repeatable: true", "Scope: declared finite search and 25-unit window", "", - "$ phaseprobe check --config examples/scipy/predator-prey.json", + "$ phaseprobe check --example scipy-predator-prey", "CHECK POLICY PASSED", "Method: DOP853 | rtol=1e-10 | vector atol=1e-12", "First-integral drift: 3.997e-15 <= 1e-8", diff --git a/scripts/installed_smoke.py b/scripts/installed_smoke.py new file mode 100644 index 0000000..593c78f --- /dev/null +++ b/scripts/installed_smoke.py @@ -0,0 +1,199 @@ +"""Exercise a PhaseProbe installation without importing from its source checkout.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import phaseprobe +from phaseprobe.config import EXAMPLES, canonical_json, load_config, load_example +from phaseprobe.errors import ConfigurationError + + +def run_cli(cwd: Path, *arguments: str, expected_returncode: int = 0) -> str: + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + completed = subprocess.run( + [sys.executable, "-m", "phaseprobe", *arguments], + check=False, + capture_output=True, + cwd=cwd, + env=environment, + text=True, + timeout=180, + ) + if completed.returncode != expected_returncode: + raise RuntimeError( + f"phaseprobe {' '.join(arguments)} exited {completed.returncode}; " + f"expected {expected_returncode}\n" + f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + return completed.stdout + + +def json_cli(cwd: Path, *arguments: str, expected_returncode: int = 0) -> dict[str, Any]: + output = run_cli(cwd, *arguments, expected_returncode=expected_returncode) + parsed = json.loads(output) + if not isinstance(parsed, dict): + raise RuntimeError(f"expected JSON object from {' '.join(arguments)}") + return parsed + + +def resolved_output_path(cwd: Path, value: object) -> Path: + if not isinstance(value, str): + raise RuntimeError(f"expected an artifact path, received {value!r}") + path = Path(value) + return (cwd / path).resolve() if not path.is_absolute() else path.resolve() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", type=Path, required=True) + parser.add_argument("--expected-version", required=True) + args = parser.parse_args() + + repo_root = args.repo_root.resolve() + working_directory = Path.cwd().resolve() + module_path = Path(phaseprobe.__file__).resolve() + if module_path.is_relative_to(repo_root): + raise RuntimeError(f"source checkout shadowed installed package: {module_path}") + if working_directory.is_relative_to(repo_root): + raise RuntimeError( + f"smoke working directory is inside source checkout: {working_directory}" + ) + if phaseprobe.__version__ != args.expected_version: + raise RuntimeError( + f"expected PhaseProbe {args.expected_version}, imported {phaseprobe.__version__}" + ) + if os.environ.get("PYTHONPATH"): + raise RuntimeError("PYTHONPATH must be unset for installed-package verification") + + loaded_examples: dict[str, str] = {} + for name in EXAMPLES: + config = load_example(name) + loaded_examples[name] = str(config.data["schema_version"]) + + help_output = run_cli(working_directory, "perturb", "--help") + if "scipy-lorenz" not in help_output: + raise RuntimeError("installed CLI help does not list the SciPy built-in examples") + + scipy_cli_results: dict[str, str] = {} + scipy_commands = { + "scipy-lorenz": ("perturb", 0, "FINITE-TIME TRAJECTORY DIVERGENCE FOUND"), + "scipy-lorenz-negative": ("perturb", 0, "NO SENSITIVE PERTURBATION FOUND"), + "scipy-predator-prey": ("check", 0, "CHECK POLICY PASSED"), + "scipy-predator-prey-coarse": ("check", 1, "CHECK POLICY FAILED"), + } + for name, (command, returncode, expected_status) in scipy_commands.items(): + result = json_cli( + working_directory, + command, + "--example", + name, + "--output-root", + f"{name} runs", + "--json", + expected_returncode=returncode, + ) + status = result.get("status") + if status != expected_status: + raise RuntimeError(f"{name} reported {status!r}; expected {expected_status!r}") + scipy_cli_results[name] = str(status) + + legacy_output = run_cli(working_directory, "perturb", "--config", "examples/scipy/lorenz.json") + if "FINITE-TIME TRAJECTORY DIVERGENCE FOUND" not in legacy_output: + raise RuntimeError("former SciPy Lorenz config path did not resolve to its built-in") + + backslash_config = load_config(Path(r"examples\scipy\lorenz.json")) + if backslash_config.data != load_example("scipy-lorenz").data: + raise RuntimeError("backslash-separated former config path resolved incorrectly") + for missing in (Path("elsewhere/lorenz.json"), Path("examples/scipy/LORENZ.json")): + try: + load_config(missing) + except ConfigurationError as exc: + if "cannot read configuration" not in str(exc): + raise RuntimeError(f"unexpected missing-config diagnostic: {exc}") from exc + else: + raise RuntimeError(f"unrelated missing path was misclassified: {missing}") + + former_path = working_directory / "examples" / "scipy" / "lorenz.json" + former_path.parent.mkdir(parents=True) + custom = load_example("scipy-lorenz-negative") + former_path.write_text(canonical_json(custom.data), encoding="utf-8") + if load_config(Path("examples/scipy/lorenz.json")).data != custom.data: + raise RuntimeError("an existing user config did not take precedence over compatibility") + + scan = json_cli( + working_directory, + "scan", + "--example", + "logistic-negative", + "--output-root", + "scan runs", + "--json", + ) + artifacts = scan.get("artifacts") + if not isinstance(artifacts, dict): + raise RuntimeError("scan did not report artifact paths") + replay_fixture = resolved_output_path(working_directory, artifacts.get("replay")) + replay = json_cli(working_directory, "replay", str(replay_fixture), "--json") + + generated = json_cli( + working_directory, + "generate-test", + str(replay_fixture), + "--output-directory", + "generated tests", + "--json", + ) + test_path = resolved_output_path(working_directory, generated.get("test")) + source = test_path.read_text(encoding="utf-8") + for forbidden in (str(repo_root), str(working_directory)): + if forbidden in source: + raise RuntimeError(f"generated pytest contains machine-specific path {forbidden!r}") + pytest_result = subprocess.run( + [sys.executable, "-m", "pytest", "-q", test_path.name], + check=False, + capture_output=True, + cwd=test_path.parent, + env={key: value for key, value in os.environ.items() if key != "PYTHONPATH"}, + text=True, + timeout=180, + ) + if pytest_result.returncode != 0: + raise RuntimeError( + f"generated pytest exited {pytest_result.returncode}\n" + f"stdout:\n{pytest_result.stdout}\nstderr:\n{pytest_result.stderr}" + ) + + print( + json.dumps( + { + "status": "PASS", + "phaseprobe_file": str(module_path), + "phaseprobe_version": phaseprobe.__version__, + "python_version": sys.version.split()[0], + "working_directory": str(working_directory), + "loaded_examples": loaded_examples, + "legacy_issue_4_command": "FINITE-TIME TRAJECTORY DIVERGENCE FOUND", + "scipy_cli_results": scipy_cli_results, + "scan": scan.get("status"), + "replay": replay.get("status"), + "generated_pytest": pytest_result.stdout.strip(), + "lorenz": scipy_cli_results["scipy-lorenz"], + "predator_prey": scipy_cli_results["scipy-predator-prey"], + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_artifacts.py b/scripts/verify_artifacts.py new file mode 100644 index 0000000..07678ec --- /dev/null +++ b/scripts/verify_artifacts.py @@ -0,0 +1,221 @@ +"""Install wheel and sdist into fresh environments and run public smoke workflows.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import venv +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SMOKE = ROOT / "scripts" / "installed_smoke.py" + + +def environment_python(environment: Path) -> Path: + if os.name == "nt": + return environment / "Scripts" / "python.exe" + return environment / "bin" / "python" + + +def run(command: list[str], *, cwd: Path, environment: dict[str, str]) -> str: + completed = subprocess.run( + command, + check=False, + capture_output=True, + cwd=cwd, + env=environment, + text=True, + timeout=600, + ) + if completed.returncode != 0: + raise RuntimeError( + f"command exited {completed.returncode}: {command!r}\n" + f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + return completed.stdout + + +def discover_one(directory: Path, pattern: str) -> Path: + matches = sorted(directory.glob(pattern)) + if len(matches) != 1: + raise RuntimeError(f"expected one {pattern} in {directory}, found {matches}") + return matches[0].resolve() + + +def project_version() -> str: + in_project = False + for raw_line in (ROOT / "pyproject.toml").read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if line == "[project]": + in_project = True + continue + if in_project and line.startswith("["): + break + key, separator, value = line.partition("=") + if in_project and separator and key.strip() == "version": + parsed = json.loads(value.strip()) + if isinstance(parsed, str): + return parsed + break + raise RuntimeError("project.version must be a quoted string") + + +def verify_base_wheel( + artifact: Path, + work_root: Path, + expected_version: str, + base_environment: dict[str, str], +) -> dict[str, str]: + environment_directory = work_root / "base wheel environment" + smoke_directory = work_root / "base wheel unrelated working directory" + smoke_directory.mkdir() + venv.EnvBuilder(with_pip=True).create(environment_directory) + python = environment_python(environment_directory) + run( + [str(python), "-m", "pip", "install", "--disable-pip-version-check", str(artifact)], + cwd=smoke_directory, + environment=base_environment, + ) + identity = run( + [ + str(python), + "-I", + "-c", + ( + "import importlib.util, json, phaseprobe; " + "assert importlib.util.find_spec('numpy') is None; " + "assert importlib.util.find_spec('scipy') is None; " + f"assert phaseprobe.__version__ == {expected_version!r}; " + "print(json.dumps({'phaseprobe_file': phaseprobe.__file__, " + "'phaseprobe_version': phaseprobe.__version__}))" + ), + ], + cwd=smoke_directory, + environment=base_environment, + ) + scan = run( + [ + str(python), + "-m", + "phaseprobe", + "scan", + "--example", + "logistic-negative", + "--output-root", + "base scan runs", + "--json", + ], + cwd=smoke_directory, + environment=base_environment, + ) + pip_check = run( + [str(python), "-m", "pip", "check"], + cwd=smoke_directory, + environment=base_environment, + ) + return { + "identity": identity.strip(), + "scan": json.loads(scan)["status"], + "pip_check": pip_check.strip(), + } + + +def verify( + kind: str, + artifact: Path, + work_root: Path, + expected_version: str, + base_environment: dict[str, str], +) -> dict[str, Any]: + environment_directory = work_root / f"{kind} environment" + smoke_directory = work_root / f"{kind} unrelated working directory" + smoke_directory.mkdir() + venv.EnvBuilder(with_pip=True).create(environment_directory) + python = environment_python(environment_directory) + run( + [ + str(python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + f"{artifact}[scipy]", + "pytest==8.4.1", + ], + cwd=smoke_directory, + environment=base_environment, + ) + pip_check = run( + [str(python), "-m", "pip", "check"], + cwd=smoke_directory, + environment=base_environment, + ).strip() + smoke_output = run( + [ + str(python), + str(SMOKE), + "--repo-root", + str(ROOT), + "--expected-version", + expected_version, + ], + cwd=smoke_directory, + environment=base_environment, + ) + smoke = json.loads(smoke_output) + if not isinstance(smoke, dict): + raise RuntimeError("installed smoke did not emit a JSON object") + return { + "artifact": str(artifact), + "pip_check": pip_check, + "smoke": smoke, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dist-dir", type=Path, required=True) + parser.add_argument("--work-root", type=Path, required=True) + parser.add_argument("--expected-version") + parser.add_argument("--artifact", choices=("both", "wheel", "sdist"), default="both") + args = parser.parse_args() + + dist_directory = args.dist_dir.resolve() + work_root = args.work_root.resolve() + if work_root.exists(): + raise RuntimeError(f"work root already exists; refusing to overwrite it: {work_root}") + work_root.mkdir(parents=True) + temporary_directory = work_root / "temporary files" + cache_directory = work_root / "pip cache" + temporary_directory.mkdir() + cache_directory.mkdir() + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + environment["PYTHONNOUSERSITE"] = "1" + environment["PIP_CACHE_DIR"] = str(cache_directory) + environment["TEMP"] = str(temporary_directory) + environment["TMP"] = str(temporary_directory) + + expected_version = args.expected_version or project_version() + selected: list[tuple[str, Path]] = [] + wheel: Path | None = None + if args.artifact in {"both", "wheel"}: + wheel = discover_one(dist_directory, "phaseprobe-*.whl") + selected.append(("wheel", wheel)) + if args.artifact in {"both", "sdist"}: + selected.append(("sdist", discover_one(dist_directory, "phaseprobe-*.tar.gz"))) + results = { + kind: verify(kind, artifact, work_root, expected_version, environment) + for kind, artifact in selected + } + if wheel is not None: + results["base-wheel"] = verify_base_wheel(wheel, work_root, expected_version, environment) + print(json.dumps({"status": "PASS", "artifacts": results}, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/phaseprobe/__init__.py b/src/phaseprobe/__init__.py index 4409919..aba796e 100644 --- a/src/phaseprobe/__init__.py +++ b/src/phaseprobe/__init__.py @@ -27,4 +27,4 @@ "run_simulation", ] -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/src/phaseprobe/cli.py b/src/phaseprobe/cli.py index c79df98..983639c 100644 --- a/src/phaseprobe/cli.py +++ b/src/phaseprobe/cli.py @@ -10,7 +10,7 @@ from phaseprobe import __version__ from phaseprobe.artifacts import ArtifactBundle, write_artifacts -from phaseprobe.config import EXAMPLE_FILES, ProbeConfig, load_config, load_example +from phaseprobe.config import EXAMPLES, ProbeConfig, load_config, load_example from phaseprobe.engine import ProbeOutcome, run_check, run_perturb, run_scan from phaseprobe.errors import ( ConfigurationError, @@ -28,7 +28,7 @@ def _add_config_source(parser: argparse.ArgumentParser) -> None: source = parser.add_mutually_exclusive_group(required=True) source.add_argument("--config", type=Path, help="versioned JSON configuration") source.add_argument( - "--example", choices=sorted(EXAMPLE_FILES), help="built-in deterministic example" + "--example", choices=sorted(EXAMPLES), help="built-in deterministic example" ) parser.add_argument( "--output-root", diff --git a/src/phaseprobe/config.py b/src/phaseprobe/config.py index 585eb80..5354917 100644 --- a/src/phaseprobe/config.py +++ b/src/phaseprobe/config.py @@ -3,10 +3,11 @@ from __future__ import annotations import json +import posixpath import re from collections.abc import Mapping from dataclasses import dataclass -from importlib import resources +from importlib import metadata, resources from pathlib import Path from typing import Any, cast @@ -66,6 +67,36 @@ def string(self, name: str, default: str | None = None) -> str: return value +@dataclass(frozen=True, slots=True) +class ExampleResource: + """One packaged example and its optional former checkout-relative path.""" + + filename: str + legacy_config_path: str | None = None + + +EXAMPLES: Mapping[str, ExampleResource] = { + "logistic": ExampleResource("logistic-scan.json"), + "logistic-negative": ExampleResource("logistic-negative.json"), + "lorenz": ExampleResource("lorenz-perturb.json"), + "lorenz-negative": ExampleResource("lorenz-negative.json"), + "predator-prey": ExampleResource("predator-prey-check.json"), + "predator-prey-negative": ExampleResource("predator-prey-negative.json"), + "toggle": ExampleResource("toggle-perturb.json"), + "toggle-negative": ExampleResource("toggle-negative.json"), + "scipy-lorenz": ExampleResource("scipy-lorenz.json", "examples/scipy/lorenz.json"), + "scipy-lorenz-negative": ExampleResource( + "scipy-lorenz-negative.json", "examples/scipy/lorenz-negative.json" + ), + "scipy-predator-prey": ExampleResource( + "scipy-predator-prey.json", "examples/scipy/predator-prey.json" + ), + "scipy-predator-prey-coarse": ExampleResource( + "scipy-predator-prey-coarse.json", "examples/scipy/predator-prey-coarse.json" + ), +} + + def parse_config(text: str, source: str) -> ProbeConfig: """Parse and validate a versioned JSON configuration.""" @@ -103,30 +134,50 @@ def load_config(path: Path) -> ProbeConfig: try: text = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + normalized = posixpath.normpath(str(path).replace("\\", "/")) + for name, example in EXAMPLES.items(): + if example.legacy_config_path == normalized: + return load_example(name) + raise ConfigurationError(f"cannot read configuration {path}: {exc}") from exc except OSError as exc: raise ConfigurationError(f"cannot read configuration {path}: {exc}") from exc return parse_config(text, str(path)) -EXAMPLE_FILES: Mapping[str, str] = { - "logistic": "logistic-scan.json", - "logistic-negative": "logistic-negative.json", - "lorenz": "lorenz-perturb.json", - "lorenz-negative": "lorenz-negative.json", - "predator-prey": "predator-prey-check.json", - "predator-prey-negative": "predator-prey-negative.json", - "toggle": "toggle-perturb.json", - "toggle-negative": "toggle-negative.json", -} +def _installed_version() -> str: + try: + return metadata.version("phaseprobe") + except metadata.PackageNotFoundError: + return "unknown" + + +def _example_help() -> str: + choices = ", ".join(sorted(EXAMPLES)) + return f"valid built-in examples are: {choices}; run 'phaseprobe --help' to list them" def load_example(name: str) -> ProbeConfig: """Load one of the immutable examples embedded in the installed wheel.""" - filename = EXAMPLE_FILES.get(name) - if filename is None: - choices = ", ".join(sorted(EXAMPLE_FILES)) - raise ConfigurationError(f"unknown example {name!r}; choose one of: {choices}") - package = resources.files("phaseprobe.data.examples") - text = package.joinpath(filename).read_text(encoding="utf-8") - return parse_config(text, f"built-in example {name}") + example = EXAMPLES.get(name) + if example is None: + raise ConfigurationError(f"unknown example {name!r}; {_example_help()}") + filename = example.filename + try: + package = resources.files("phaseprobe.data.examples") + text = package.joinpath(filename).read_text(encoding="utf-8") + except (FileNotFoundError, ModuleNotFoundError, OSError) as exc: + raise ConfigurationError( + f"cannot load built-in example {name!r} for execution " + f"(PhaseProbe {_installed_version()}): packaged resource {filename!r} is unavailable; " + f"{_example_help()}. Reinstall PhaseProbe from a complete wheel or source archive." + ) from exc + try: + return parse_config(text, f"built-in example {name}") + except (ConfigurationError, UnicodeError) as exc: + raise ConfigurationError( + f"cannot load built-in example {name!r} for execution " + f"(PhaseProbe {_installed_version()}): packaged resource {filename!r} is malformed: " + f"{exc}; {_example_help()}" + ) from exc diff --git a/src/phaseprobe/data/examples/scipy-lorenz-negative.json b/src/phaseprobe/data/examples/scipy-lorenz-negative.json new file mode 100644 index 0000000..d875461 --- /dev/null +++ b/src/phaseprobe/data/examples/scipy-lorenz-negative.json @@ -0,0 +1,54 @@ +{ + "schema_version": "2.0", + "model": "lorenz-scipy", + "seed": 23, + "adapter": { + "kind": "python", + "module": "phaseprobe.examples.scipy_models", + "factory": "lorenz_adapter", + "options": { + "identity": "phaseprobe-lorenz-scipy-v1", + "state_names": ["x", "y", "z"], + "initial_state": [1.0, 1.0, 1.0], + "t_span": [0.0, 5.0], + "t_eval": {"kind": "linspace", "points": 401}, + "method": "DOP853", + "rtol": 1e-9, + "atol": 1e-12, + "max_step": 0.05, + "vectorized": false, + "dense_output": false + } + }, + "parameters": {"sigma": 10.0, "rho": 28.0, "beta": 2.6666666666666665}, + "simulation": {"trace_cap": 401, "hard_state_limit": 1000.0}, + "tolerances": {"state_bound": 100.0}, + "perturb": { + "dimension": "x", + "start": 1e-9, + "stop": 1e-5, + "points": 5, + "scale": "log", + "predicate": "finite-time-divergence", + "divergence_threshold": 1.0, + "refine_iterations": 0, + "repeatability": 2 + }, + "replay": { + "mode": "tolerance", + "state_atol": 1e-6, + "state_rtol": 1e-6, + "observable_atol": {"x": 1e-6, "y": 1e-6, "z": 1e-6, "radius": 2e-6}, + "invariant_measure_atol": 1e-6, + "endpoint_time_atol": 1e-10, + "event_time_atol": 1e-8, + "retained_grid_time_atol": 1e-12, + "max_unmatched_points": 0, + "expected_solver_success": true, + "require_classifier": true, + "require_invariants": true + }, + "classification_rule": "Short-window negative control for the same finite-time trajectory-distance predicate.", + "refinement_rule": "Evaluate only the declared bounded perturbation grid.", + "invalid_state_policy": "Abort on solver failure, invalid shape, NaN, infinity, or the declared hard-state-limit breach." +} diff --git a/src/phaseprobe/data/examples/scipy-lorenz.json b/src/phaseprobe/data/examples/scipy-lorenz.json new file mode 100644 index 0000000..af26441 --- /dev/null +++ b/src/phaseprobe/data/examples/scipy-lorenz.json @@ -0,0 +1,54 @@ +{ + "schema_version": "2.0", + "model": "lorenz-scipy", + "seed": 23, + "adapter": { + "kind": "python", + "module": "phaseprobe.examples.scipy_models", + "factory": "lorenz_adapter", + "options": { + "identity": "phaseprobe-lorenz-scipy-v1", + "state_names": ["x", "y", "z"], + "initial_state": [1.0, 1.0, 1.0], + "t_span": [0.0, 25.0], + "t_eval": {"kind": "linspace", "points": 1001}, + "method": "DOP853", + "rtol": 1e-9, + "atol": 1e-12, + "max_step": 0.05, + "vectorized": false, + "dense_output": false + } + }, + "parameters": {"sigma": 10.0, "rho": 28.0, "beta": 2.6666666666666665}, + "simulation": {"trace_cap": 1001, "hard_state_limit": 1000.0}, + "tolerances": {"state_bound": 100.0}, + "perturb": { + "dimension": "x", + "start": 1e-9, + "stop": 1e-5, + "points": 5, + "scale": "log", + "predicate": "finite-time-divergence", + "divergence_threshold": 1.0, + "refine_iterations": 6, + "repeatability": 2 + }, + "replay": { + "mode": "tolerance", + "state_atol": 1e-6, + "state_rtol": 1e-6, + "observable_atol": {"x": 1e-6, "y": 1e-6, "z": 1e-6, "radius": 2e-6}, + "invariant_measure_atol": 1e-6, + "endpoint_time_atol": 1e-10, + "event_time_atol": 1e-8, + "retained_grid_time_atol": 1e-12, + "max_unmatched_points": 0, + "expected_solver_success": true, + "require_classifier": true, + "require_invariants": true + }, + "classification_rule": "Classify only boundedness over the declared finite window; the finding is twin-trajectory separation, not a proof of chaos.", + "refinement_rule": "Search the declared positive x perturbations and repeat the smallest threshold crossing found in the bounded grid.", + "invalid_state_policy": "Abort on solver failure, invalid shape, NaN, infinity, or the declared hard-state-limit breach." +} diff --git a/src/phaseprobe/data/examples/scipy-predator-prey-coarse.json b/src/phaseprobe/data/examples/scipy-predator-prey-coarse.json new file mode 100644 index 0000000..f65358f --- /dev/null +++ b/src/phaseprobe/data/examples/scipy-predator-prey-coarse.json @@ -0,0 +1,45 @@ +{ + "schema_version": "2.0", + "model": "predator-prey-scipy", + "seed": 31, + "adapter": { + "kind": "python", + "module": "phaseprobe.examples.scipy_models", + "factory": "predator_prey_adapter", + "options": { + "identity": "phaseprobe-predator-prey-scipy-v1", + "state_names": ["prey", "predator"], + "initial_state": [10.0, 5.0], + "t_span": [0.0, 30.0], + "t_eval": {"kind": "linspace", "points": 301}, + "method": "RK23", + "rtol": 0.01, + "atol": 0.0001, + "max_step": 2.0, + "vectorized": false, + "dense_output": false + } + }, + "parameters": {"alpha": 1.1, "beta": 0.4, "delta": 0.1, "gamma": 0.4}, + "simulation": {"trace_cap": 301, "hard_state_limit": 1000.0}, + "tolerances": {"invariant_drift": 0.001}, + "check": {"analysis": "invariants"}, + "policy": {"forbid_findings": true, "require_finding": false, "require_invariants": true}, + "replay": { + "mode": "tolerance", + "state_atol": 1e-5, + "state_rtol": 1e-5, + "observable_atol": {"prey": 1e-5, "predator": 1e-5, "first_integral": 1e-5}, + "invariant_measure_atol": 1e-5, + "endpoint_time_atol": 1e-9, + "event_time_atol": 1e-7, + "retained_grid_time_atol": 1e-12, + "max_unmatched_points": 0, + "expected_solver_success": true, + "require_classifier": true, + "require_invariants": true + }, + "classification_rule": "Deliberately loose/coarse negative control; positivity alone does not satisfy the invariant policy.", + "refinement_rule": "Compare its first-integral drift with the tight DOP853 configuration.", + "invalid_state_policy": "Abort on solver failure, invalid shape, NaN, infinity, or non-finite observables." +} diff --git a/src/phaseprobe/data/examples/scipy-predator-prey.json b/src/phaseprobe/data/examples/scipy-predator-prey.json new file mode 100644 index 0000000..d5de4e3 --- /dev/null +++ b/src/phaseprobe/data/examples/scipy-predator-prey.json @@ -0,0 +1,45 @@ +{ + "schema_version": "2.0", + "model": "predator-prey-scipy", + "seed": 31, + "adapter": { + "kind": "python", + "module": "phaseprobe.examples.scipy_models", + "factory": "predator_prey_adapter", + "options": { + "identity": "phaseprobe-predator-prey-scipy-v1", + "state_names": ["prey", "predator"], + "initial_state": [10.0, 5.0], + "t_span": [0.0, 30.0], + "t_eval": {"kind": "linspace", "points": 1201}, + "method": "DOP853", + "rtol": 1e-10, + "atol": [1e-12, 1e-12], + "max_step": 0.05, + "vectorized": false, + "dense_output": false + } + }, + "parameters": {"alpha": 1.1, "beta": 0.4, "delta": 0.1, "gamma": 0.4}, + "simulation": {"trace_cap": 1201, "hard_state_limit": 1000.0}, + "tolerances": {"invariant_drift": 1e-8}, + "check": {"analysis": "invariants"}, + "policy": {"forbid_findings": true, "require_finding": false, "require_invariants": true}, + "replay": { + "mode": "tolerance", + "state_atol": 1e-7, + "state_rtol": 1e-7, + "observable_atol": {"prey": 1e-7, "predator": 1e-7, "first_integral": 1e-8}, + "invariant_measure_atol": 1e-8, + "endpoint_time_atol": 1e-10, + "event_time_atol": 1e-8, + "retained_grid_time_atol": 1e-12, + "max_unmatched_points": 0, + "expected_solver_success": true, + "require_classifier": true, + "require_invariants": true + }, + "classification_rule": "Require both populations to remain positive over the declared retained grid.", + "refinement_rule": "DOP853 with tight tolerances and a bounded maximum step; compare with the committed coarse negative control.", + "invalid_state_policy": "Abort on solver failure, invalid shape, NaN, infinity, or non-finite observables." +} diff --git a/src/phaseprobe/generate.py b/src/phaseprobe/generate.py index 5b03a3b..b91832b 100644 --- a/src/phaseprobe/generate.py +++ b/src/phaseprobe/generate.py @@ -3,7 +3,6 @@ from __future__ import annotations import re -import shutil from dataclasses import dataclass from pathlib import Path @@ -23,6 +22,17 @@ def _safe_name(value: object) -> str: return candidate[:64] or "model" +def _reject_conflict(path: Path, expected: bytes, description: str) -> bool: + if not path.exists(): + return True + if not path.is_file() or path.read_bytes() != expected: + raise FileExistsError( + f"refusing to overwrite existing {description} {path}; " + "choose another --output-directory" + ) + return False + + def generate_regression_test(fixture: Path, output_directory: Path) -> GeneratedTest: """Validate evidence, copy its fixture, and emit a non-extensible pytest template.""" @@ -33,11 +43,8 @@ def generate_regression_test(fixture: Path, output_directory: Path) -> Generated "replay fixture does not reproduce; refusing to generate a regression test" ) model_name = _safe_name(payload.get("model")) - output_directory.mkdir(parents=True, exist_ok=True) fixture_directory = output_directory / "fixtures" - fixture_directory.mkdir(exist_ok=True) copied_fixture = fixture_directory / f"{model_name}-replay.json" - shutil.copyfile(fixture, copied_fixture) test_path = output_directory / f"test_{model_name}_transition.py" baseline = payload.get("baseline") metadata = baseline.get("execution_metadata") if isinstance(baseline, dict) else None @@ -68,5 +75,14 @@ def test_{model_name}_transition_replays() -> None: result = verify_replay(FIXTURE) assert result.ok, result.as_dict() ''' - test_path.write_text(source, encoding="utf-8", newline="\n") + fixture_bytes = fixture.read_bytes() + source_bytes = source.encode("utf-8") + write_fixture = _reject_conflict(copied_fixture, fixture_bytes, "replay fixture") + write_test = _reject_conflict(test_path, source_bytes, "generated pytest") + output_directory.mkdir(parents=True, exist_ok=True) + fixture_directory.mkdir(exist_ok=True) + if write_fixture: + copied_fixture.write_bytes(fixture_bytes) + if write_test: + test_path.write_bytes(source_bytes) return GeneratedTest(test_path=test_path, fixture_path=copied_fixture) diff --git a/src/phaseprobe/py.typed b/src/phaseprobe/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/phaseprobe/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_artifacts_replay.py b/tests/test_artifacts_replay.py index 93e26f9..2ddb32e 100644 --- a/tests/test_artifacts_replay.py +++ b/tests/test_artifacts_replay.py @@ -53,6 +53,10 @@ def test_replay_rejects_tampering(artifact_run: Path) -> None: @pytest.mark.integration def test_generated_pytest_genuinely_executes(artifact_run: Path, tmp_path: Path) -> None: generated = generate_regression_test(artifact_run / "replay.json", tmp_path / "generated") + assert str(tmp_path) not in generated.test_path.read_text(encoding="utf-8") + assert ( + generate_regression_test(artifact_run / "replay.json", tmp_path / "generated") == generated + ) environment = { name: value for name, value in os.environ.items() @@ -71,6 +75,15 @@ def test_generated_pytest_genuinely_executes(artifact_run: Path, tmp_path: Path) assert "1 passed" in completed.stdout +def test_generated_pytest_refuses_conflicting_overwrite(artifact_run: Path, tmp_path: Path) -> None: + output_directory = tmp_path / "generated" + generated = generate_regression_test(artifact_run / "replay.json", output_directory) + generated.test_path.write_text("# user-owned test\n", encoding="utf-8") + with pytest.raises(FileExistsError, match="refusing to overwrite"): + generate_regression_test(artifact_run / "replay.json", output_directory) + assert generated.test_path.read_text(encoding="utf-8") == "# user-owned test\n" + + def test_html_report_is_self_contained_and_offline(artifact_run: Path) -> None: report = (artifact_run / "report.html").read_text(encoding="utf-8") assert "" in report diff --git a/tests/test_config.py b/tests/test_config.py index ea2c168..61a40be 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,11 +3,19 @@ from __future__ import annotations import json +from importlib import resources from pathlib import Path import pytest -from phaseprobe.config import canonical_json, load_config, load_example, parse_config +from phaseprobe.config import ( + EXAMPLES, + SUPPORTED_CONFIG_SCHEMA_VERSIONS, + canonical_json, + load_config, + load_example, + parse_config, +) from phaseprobe.errors import ConfigurationError from phaseprobe.models import get_model, model_names from phaseprobe.types import ModelAdapter @@ -38,24 +46,95 @@ def test_load_config_reports_missing_file(tmp_path: Path) -> None: def test_all_built_in_examples_are_versioned_json() -> None: - for name in ( - "logistic", - "logistic-negative", - "lorenz", - "lorenz-negative", - "predator-prey", - "predator-prey-negative", - "toggle", - "toggle-negative", - ): + for name in EXAMPLES: config = load_example(name) - assert config.data["schema_version"] == "1.0" + assert config.data["schema_version"] in SUPPORTED_CONFIG_SCHEMA_VERSIONS json.loads(canonical_json(config.data)) +def test_built_in_registry_matches_packaged_json_resources() -> None: + package = resources.files("phaseprobe.data.examples") + packaged = {item.name for item in package.iterdir() if item.name.endswith(".json")} + assert {example.filename for example in EXAMPLES.values()} == packaged + + +@pytest.mark.parametrize( + "name", [name for name, example in EXAMPLES.items() if example.legacy_config_path] +) +def test_packaged_scipy_examples_match_source_checkout_copies(name: str) -> None: + legacy_path = EXAMPLES[name].legacy_config_path + assert legacy_path is not None + source = Path(__file__).resolve().parents[1] / legacy_path + assert load_example(name).data == load_config(source).data + + +@pytest.mark.parametrize( + "name", [name for name, example in EXAMPLES.items() if example.legacy_config_path] +) +def test_missing_former_scipy_path_loads_packaged_example( + name: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + legacy_path = EXAMPLES[name].legacy_config_path + assert legacy_path is not None + monkeypatch.chdir(tmp_path) + assert load_config(Path(legacy_path)).data == load_example(name).data + assert load_config(Path(legacy_path.replace("/", "\\"))).data == load_example(name).data + + +def test_existing_former_scipy_path_takes_precedence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + legacy_path = EXAMPLES["scipy-lorenz"].legacy_config_path + assert legacy_path is not None + config_path = tmp_path / legacy_path + config_path.parent.mkdir(parents=True) + source = Path(__file__).resolve().parents[1] / "examples" / "configs" / "logistic-negative.json" + config_path.write_bytes(source.read_bytes()) + monkeypatch.chdir(tmp_path) + loaded = load_config(Path(legacy_path)) + assert loaded.model == "logistic-map" + assert Path(loaded.source) == Path(legacy_path) + + +@pytest.mark.parametrize( + "missing_path", + ["elsewhere/lorenz.json", "examples/scipy/LORENZ.json", "lorenz.json"], +) +def test_missing_config_does_not_use_fuzzy_example_fallback( + missing_path: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + with pytest.raises(ConfigurationError, match="cannot read configuration"): + load_config(Path(missing_path)) + + def test_unknown_example_is_actionable() -> None: - with pytest.raises(ConfigurationError, match="unknown example"): + with pytest.raises(ConfigurationError, match="unknown example") as error: load_example("missing") + assert "phaseprobe --help" in str(error.value) + + +def test_missing_built_in_resource_is_actionable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("phaseprobe.config.resources.files", lambda package: tmp_path) + with pytest.raises(ConfigurationError, match="packaged resource") as error: + load_example("scipy-lorenz") + message = str(error.value) + assert "PhaseProbe" in message + assert "scipy-lorenz.json" in message + assert "Reinstall PhaseProbe" in message + assert "phaseprobe --help" in message + + +def test_malformed_built_in_resource_is_actionable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "scipy-lorenz.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr("phaseprobe.config.resources.files", lambda package: tmp_path) + with pytest.raises(ConfigurationError, match="malformed") as error: + load_example("scipy-lorenz") + assert "unsupported configuration schema" in str(error.value) def test_registry_models_implement_protocol() -> None: diff --git a/tests/test_optional_dependency.py b/tests/test_optional_dependency.py index 7c04003..90ad800 100644 --- a/tests/test_optional_dependency.py +++ b/tests/test_optional_dependency.py @@ -6,11 +6,13 @@ import os import subprocess import sys +from importlib import metadata from pathlib import Path from types import SimpleNamespace import pytest +from phaseprobe import __version__ from phaseprobe.adapters.loader import load_configured_adapter from phaseprobe.config import parse_config from phaseprobe.errors import ConfigurationError @@ -35,7 +37,11 @@ def _isolated(command: str) -> subprocess.CompletedProcess[str]: def test_core_imports_without_site_packages_or_scipy() -> None: completed = _isolated("import phaseprobe; print(phaseprobe.__version__)") assert completed.returncode == 0, completed.stderr - assert completed.stdout.strip() == "0.2.0" + assert completed.stdout.strip() == __version__ + + +def test_distribution_and_module_versions_match() -> None: + assert metadata.version("phaseprobe") == __version__ def test_scipy_adapter_import_has_actionable_optional_extra_error() -> None: diff --git a/tests/test_scipy_adapter.py b/tests/test_scipy_adapter.py index 6180af6..747ea96 100644 --- a/tests/test_scipy_adapter.py +++ b/tests/test_scipy_adapter.py @@ -17,14 +17,15 @@ import numpy.typing as npt import scipy +import phaseprobe from phaseprobe import run_perturbation, run_simulation from phaseprobe.adapters.scipy import EventSpec, SolveIVPAdapter from phaseprobe.artifacts import write_artifacts -from phaseprobe.config import ProbeConfig, load_config, parse_config +from phaseprobe.config import ProbeConfig, load_example, parse_config from phaseprobe.engine import ProbeOutcome, run_check, run_perturb, simulate from phaseprobe.errors import ConfigurationError, NumericalFailure from phaseprobe.generate import generate_regression_test -from phaseprobe.replay import verify_replay +from phaseprobe.replay import validate_fixture, verify_replay from phaseprobe.types import Parameters pytestmark = pytest.mark.scipy @@ -362,6 +363,30 @@ def half_value(time: float, state: FloatArray, parameters: Parameters) -> float: assert trace.final_state[0] == pytest.approx(0.5, rel=1e-8) +def test_backward_time_directional_event_has_analytic_crossing_time() -> None: + def half_value(time: float, state: FloatArray, parameters: Parameters) -> float: + return float(state[0] - 0.5) + + adapter = SolveIVPAdapter( + name="backward-exponential", + identity="backward-exponential-v1", + rhs=exponential_rhs, + state_names=("value",), + initial_state=(float(np.exp(-2.0)),), + t_span=(2.0, 0.0), + t_eval=41, + method="DOP853", + rtol=1e-10, + atol=1e-12, + max_step=0.05, + events=(EventSpec("half-value", half_value, terminal=True, direction=1),), + ) + trace = adapter.simulate(adapter.initial_state({}, 0), {"rate": 1.0}, {}, 0) + assert trace.status == 1 + assert trace.metadata["termination_time"] == pytest.approx(np.log(2.0), rel=1e-9) + assert trace.final_state[0] == pytest.approx(0.5, rel=1e-9) + + def test_invalid_initial_state_and_nan_rhs_are_rejected() -> None: with pytest.raises(ConfigurationError, match="finite"): SolveIVPAdapter( @@ -424,22 +449,22 @@ def test_engine_enforces_bounded_trace_retention() -> None: @pytest.fixture(scope="session") def lorenz_positive() -> ProbeOutcome: - return run_perturb(load_config(ROOT / "examples" / "scipy" / "lorenz.json")) + return run_perturb(load_example("scipy-lorenz")) @pytest.fixture(scope="session") def lorenz_negative() -> ProbeOutcome: - return run_perturb(load_config(ROOT / "examples" / "scipy" / "lorenz-negative.json")) + return run_perturb(load_example("scipy-lorenz-negative")) @pytest.fixture(scope="session") def predator_prey_tight() -> ProbeOutcome: - return run_check(load_config(ROOT / "examples" / "scipy" / "predator-prey.json")) + return run_check(load_example("scipy-predator-prey")) @pytest.fixture(scope="session") def predator_prey_coarse() -> ProbeOutcome: - return run_check(load_config(ROOT / "examples" / "scipy" / "predator-prey-coarse.json")) + return run_check(load_example("scipy-predator-prey-coarse")) def test_lorenz_positive_is_only_finite_time_divergence(lorenz_positive: ProbeOutcome) -> None: @@ -471,6 +496,27 @@ def test_tolerance_replay_preserves_integrity_and_executes_generated_pytest( ) -> None: bundle = write_artifacts(predator_prey_tight, tmp_path / "runs") verification = verify_replay(bundle.replay_json) + fixture = validate_fixture(bundle.replay_json) + assert fixture["created_by"] == f"phaseprobe {phaseprobe.__version__}" + baseline = fixture["baseline"] + assert isinstance(baseline, dict) + metadata = baseline["execution_metadata"] + assert isinstance(metadata, dict) + assert { + "python_version", + "numpy_version", + "scipy_version", + "solver_method", + "rtol", + "atol", + "maximum_step", + "evaluation_grid", + "t_span", + "initial_state", + "parameters", + "event_configuration", + "seed", + } <= metadata.keys() assert verification.ok assert verification.mode == "tolerance" assert verification.comparisons[0]["state_tolerance_match"] is True diff --git a/uv.lock b/uv.lock index 36b4750..5464445 100644 --- a/uv.lock +++ b/uv.lock @@ -390,7 +390,7 @@ wheels = [ [[package]] name = "phaseprobe" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } [package.optional-dependencies]