Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
name: Publish an existing GitHub release to PyPI

on:
workflow_dispatch:
inputs:
tag:
description: Immutable release tag (for example, v0.2.0)
required: true
type: string
version:
description: Expected package version (for example, 0.2.0)
required: true
type: string
commit:
description: Expected 40-character commit for the tag
required: true
type: string
wheel_sha256:
description: Expected SHA-256 for the wheel release asset
required: true
type: string
sdist_sha256:
description: Expected SHA-256 for the source release asset
required: true
type: string

permissions: {}

concurrency:
group: pypi-${{ inputs.tag }}
cancel-in-progress: false

jobs:
verify:
name: Verify immutable release assets
if: >-
github.repository == 'aliengineering-byte/phaseprobe' &&
github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Download and verify the selected release
env:
TAG: ${{ inputs.tag }}
VERSION: ${{ inputs.version }}
EXPECTED_COMMIT: ${{ inputs.commit }}
WHEEL_SHA256: ${{ inputs.wheel_sha256 }}
SDIST_SHA256: ${{ inputs.sdist_sha256 }}
shell: bash
run: |
set -euo pipefail

[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$TAG" == "v$VERSION" ]]
[[ "$EXPECTED_COMMIT" =~ ^[0-9a-f]{40}$ ]]
[[ "$WHEEL_SHA256" =~ ^[0-9a-f]{64}$ ]]
[[ "$SDIST_SHA256" =~ ^[0-9a-f]{64}$ ]]

repository_url="https://github.com/${GITHUB_REPOSITORY}.git"
tag_refs="$(git ls-remote "$repository_url" "refs/tags/$TAG" "refs/tags/$TAG^{}")"
direct_commit="$(awk -v ref="refs/tags/$TAG" '$2 == ref { print $1 }' <<<"$tag_refs")"
peeled_commit="$(awk -v ref="refs/tags/$TAG^{}" '$2 == ref { print $1 }' <<<"$tag_refs")"
resolved_commit="${peeled_commit:-$direct_commit}"
[[ -n "$resolved_commit" ]]
[[ "$resolved_commit" == "$EXPECTED_COMMIT" ]]

wheel="phaseprobe-$VERSION-py3-none-any.whl"
sdist="phaseprobe-$VERSION.tar.gz"
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/$TAG"
mkdir dist
curl --fail --location --proto '=https' --tlsv1.2 \
--output "dist/$wheel" "$release_url/$wheel"
curl --fail --location --proto '=https' --tlsv1.2 \
--output "dist/$sdist" "$release_url/$sdist"
printf '%s %s\n%s %s\n' \
"$WHEEL_SHA256" "dist/$wheel" \
"$SDIST_SHA256" "dist/$sdist" | sha256sum --check --strict

python3 -m venv .twine-check
.twine-check/bin/python -m pip install --disable-pip-version-check 'twine==7.0.0'
.twine-check/bin/python -m twine check "dist/$wheel" "dist/$sdist"

python3 - "$VERSION" "dist/$wheel" "dist/$sdist" <<'PY'
import email
import sys
import tarfile
import zipfile

expected_version, wheel_path, sdist_path = sys.argv[1:]

with zipfile.ZipFile(wheel_path) as archive:
metadata_names = [
name for name in archive.namelist() 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:
metadata_members = [
member
for member in archive.getmembers()
if member.isfile() and member.name.count("/") == 1 and member.name.endswith("/PKG-INFO")
]
assert len(metadata_members) == 1, [member.name for member in metadata_members]
extracted = archive.extractfile(metadata_members[0])
assert extracted is not None
sdist_metadata = email.message_from_bytes(extracted.read())

for metadata in (wheel_metadata, sdist_metadata):
assert metadata["Name"] == "phaseprobe"
assert metadata["Version"] == expected_version
assert metadata["Requires-Python"] == ">=3.10"
assert metadata["License-Expression"] == "Apache-2.0"
assert "scipy" in metadata.get_all("Provides-Extra", [])
requirements = metadata.get_all("Requires-Dist", [])
assert any(req.startswith("numpy") and "extra == 'scipy'" in req for req in requirements)
assert any(req.startswith("scipy") and "extra == 'scipy'" in req for req in requirements)
project_urls = metadata.get_all("Project-URL", [])
assert any(url.startswith("Source, https://github.com/aliengineering-byte/phaseprobe") for url in project_urls)
PY

- name: Store the verified distributions
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: verified-distributions-${{ inputs.tag }}
path: dist/*
if-no-files-found: error
retention-days: 1

publish:
name: Publish verified distributions
needs: verify
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/phaseprobe/${{ inputs.version }}/
permissions:
id-token: write
steps:
- name: Retrieve the verified distributions
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: verified-distributions-${{ inputs.tag }}
path: dist

- name: Publish distributions to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
with:
packages-dir: dist/
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
$ python -m pip install .
$ pip install phaseprobe
$ phaseprobe scan --example logistic
QUALITATIVE TRANSITION FOUND

Expand All @@ -24,12 +24,19 @@ $ python -m pytest -q tests/generated

No API key, LLM, GPU, Docker, account, telemetry, network connection, or hosted service is required at runtime. PhaseProbe's base installation has no third-party runtime dependencies; NumPy and SciPy are isolated in the optional `scipy` extra.

## Installation

```bash
pip install phaseprobe
pip install "phaseprobe[scipy]"
```

## Five-minute quick start

Requires Python 3.10 or newer on Windows or Linux.

```bash
python -m pip install .
pip install phaseprobe
phaseprobe scan --example logistic
phaseprobe replay .phaseprobe/runs/<run-id>/replay.json
phaseprobe generate-test .phaseprobe/runs/<run-id>/replay.json
Expand Down
51 changes: 42 additions & 9 deletions docs/upstream/SCIPY_INTEGRATION_PROPOSAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,48 @@ propose this external qualitative regression/replay layer.
Project quick start and terminal assets:
https://github.com/aliengineering-byte/phaseprobe#use-with-scipy

## Suggested discussion title
## Discussion title and body

`PhaseProbe: tolerance-aware qualitative regression fixtures for SciPy solve_ivp models`
`PhaseProbe: reproducible transition detection and regression generation for solve_ivp models`

## Human-editable outreach message
Hi SciPy community,

> I built an optional, dependency-isolated PhaseProbe adapter for public `solve_ivp` models. It
> keeps SciPy responsible for integration and adds bounded qualitative searches, explicit
> tolerance replay, and generated pytest regressions. The examples include finite-time Lorenz
> divergence with a negative control and a Lotka–Volterra invariant refinement check. I reviewed
> related tolerance, event, work-bound, and callback discussions and do not propose a SciPy API
> change. Would this workflow be useful to users maintaining scientific simulation regressions?
I built PhaseProbe, an open-source downstream testing tool for dynamical-system simulations. It
uses `scipy.integrate.solve_ivp` through a trajectory-level adapter to search bounded parameter or
initial-condition regions for reproducible qualitative transitions, preserve replay evidence, and
generate pytest regression tests from the findings.

The motivation is a practical testing problem: a numerical model can continue integrating
successfully while a small parameter or initial-condition change moves it into a different
qualitative regime. PhaseProbe records the search configuration, solver method and tolerances,
environment evidence, trajectory hashes, observables, classifications, and transition bracket so
that the result can be independently replayed.

The current release includes examples for Lorenz finite-time divergence and predator–prey
invariant drift. For adaptive SciPy integrations, replay is tolerance-based rather than claimed to
be bit-exact. The results are explicitly bounded, finite-time numerical evidence—not proofs of
chaos, exact bifurcation locations, Lyapunov estimates, or globally minimal perturbations.

Repository:
https://github.com/aliengineering-byte/phaseprobe

Release:
https://github.com/aliengineering-byte/phaseprobe/releases/tag/v0.2.0

PyPI:
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

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
scientifically and practically appropriate.
2. Whether the trajectory-level adapter and recorded solver/environment metadata omit evidence
that SciPy users would expect.
3. Whether a downstream example or ecosystem/documentation reference could be appropriate if the
tool proves useful to users.

Feedback on the design, terminology, numerical claims, and useful real-world test cases would be
very welcome.
Loading