diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index dc48ffd..f6a767f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,5 +1,8 @@ name: Nightly SDK Breakage Detection +# Installs the newest releases of every dependency and fails visibly when the +# install, dependency resolution or tests break. Do not add continue-on-error, +# "|| true" or "|| echo" here: a red nightly run is the signal. on: schedule: - cron: "0 2 * * *" @@ -9,27 +12,22 @@ jobs: nightly: name: Test against latest SDK versions runs-on: ubuntu-latest - continue-on-error: true steps: - uses: actions/checkout@v4 - - name: Checkout WestQuant Core + - name: Checkout WestQuant Core main uses: actions/checkout@v4 with: repository: WestQuantOpen/westquant-core path: _westquant-core - uses: actions/setup-python@v5 with: - python-version: "3.10" - - name: Install latest SDKs + python-version: "3.12" + - name: Install latest dependencies run: | - pip install --upgrade pip - pip install -e ./_westquant-core[test] - pip install --upgrade qiskit pytket pennylane pulser 2>/dev/null || true - pip install -e .[test] 2>/dev/null || pip install -e . + python -m pip install --upgrade pip + pip install -e ./_westquant-core + pip install --upgrade --upgrade-strategy eager -e ".[test]" + pip check + pip freeze - name: Run tests - run: | - pytest tests/ -v || echo "Nightly tests failed — SDK breakage detected" - - name: Report status - if: always() - run: | - echo "Nightly run completed. Check for SDK breakage above." + run: pytest tests/ -v diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cfdfa43..40a9cc5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,40 +1,55 @@ name: Publish to PyPI +# Single publication path. PyPI Trusted Publishing is bound to this file name +# (publish.yml) and the "pypi" environment. Tests must pass and the release tag +# must match the pyproject version before anything is uploaded. on: release: types: [published] workflow_dispatch: - inputs: - confirm: - description: 'Type "publish" to confirm' - required: true - default: 'publish' + +permissions: + contents: read jobs: - publish: - name: Build and publish + test: + uses: ./.github/workflows/tests.yml + + build: + needs: test runs-on: ubuntu-latest - environment: pypi - permissions: - id-token: write steps: - uses: actions/checkout@v4 - - name: Checkout WestQuant Core - uses: actions/checkout@v4 - with: - repository: WestQuantOpen/westquant-core - path: _westquant-core - uses: actions/setup-python@v5 with: - python-version: "3.10" - - name: Build package + python-version: "3.12" + - name: Check release tag matches package version + if: github.event_name == 'release' run: | - pip install -e ./_westquant-core - pip install build - python -m build - - name: Check package + version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + if [ "${GITHUB_REF_NAME#v}" != "$version" ]; then + echo "::error::Release tag $GITHUB_REF_NAME does not match pyproject version $version" + exit 1 + fi + - name: Build and check distributions run: | - pip install twine - twine check dist/* - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + python -m pip install --upgrade build twine + python -m build + twine check --strict dist/* + - uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b02ed70..5603c0c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,7 @@ name: Tests on: + workflow_call: push: branches: [main] pull_request: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f5ece66 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## 0.1.1 + +- Fix: `westquant compare` printed `verified=yes` for every Qiskit baseline and + `Verification: EXACT` for the WestQuant result without running any check. + Each row now shows the status returned by `QiskitEquivalenceVerifier` + (`exact`, `invalid` or `unknown`), and reductions are only printed when the + selected candidate is verified. +- `westquant compare -f pytket|pennylane|pulser` now exits with status 2 and a + "not implemented" message instead of exiting 0. +- `-o 0` is no longer replaced by optimization level 2. +- `westquant version` / `doctor` report the installed package version instead + of a hard-coded string. +- README: removed `from westquant import optimize` (never exported) and the + synthetic compare output; documented the real search API and actual output. +- The `qiskit` and `all` extras now require `westquant-qiskit>=0.1.2`, whose + stage plugins work on Qiskit 2.x. +- `westquant-qcsc` 0.2.0 no longer installs a `westquant` command, so this + package is the only owner of `westquant`. diff --git a/README.md b/README.md index 311e872..e19ae65 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,16 @@ pip install westquant[qiskit] ``` ```python -from westquant import optimize +from westquant_qiskit.search import DeterministicSearchEngine -result = optimize(circuit, framework="qiskit", backend=backend, budget=64) -print(result.pareto_front) +result = DeterministicSearchEngine().search(circuit, challenge_id="demo", backend=backend, max_candidates=64) +print(result.best.metrics, result.best.verification.equivalence) +print([c.metrics for c in result.pareto_front]) ``` +The umbrella package itself exports the WQDF dataset helpers +(`from westquant import generate_training_data, WQDFSample`). + Or from the command line: ```bash @@ -88,19 +92,24 @@ pip install westquant[all] westquant compare circuit.qasm -f qiskit ``` -Output: +Actual output for a 5-qubit QFT (westquant 0.1.1, Qiskit 2.6.1, no target): ``` - 2Q gates depth verified -Qiskit default 143 212 yes -Qiskit opt-3 128 195 yes -WestQuant search 113 179 yes - -Candidates evaluated: 64 -Verification: EXACT -2Q gate reduction: 20.9% -Depth reduction: 15.6% + 2Q gates depth verification + ──────────────────── ────────── ────────── ──────────── + Qiskit opt-2 (base) 26 29 exact + Qiskit opt-3 26 29 exact + WestQuant search 26 34 exact + + Candidates evaluated: 8 (8 verified exact) + Verification of selected candidate: exact ``` +The verification column is the status returned by `QiskitEquivalenceVerifier` +for that exact circuit: `exact`, `invalid` or `unknown`. Circuits with +measurements, resets or more than 8 qubits are reported as `unknown`, and +reductions are then not printed. Only `-f qiskit` is implemented; other +frameworks exit with status 2. + ## Architecture ``` @@ -118,9 +127,9 @@ WestQuant Open ## Verification -Every transformation is independently verified: -- **EXACT**: Unitary equivalence via `Operator.equiv()` -- **UNKNOWN**: Conservative — never promoted to EXACT +Every candidate carries an explicit verification status: +- **EXACT**: Unitary equivalence via `Operator.equiv()` (small, unitary circuits) +- **UNKNOWN**: Conservative — not checked or not checkable; never promoted to EXACT - **same_problem_different_dynamics**: Pulser-specific — never collapsed with EXACT ## Design philosophy diff --git a/pyproject.toml b/pyproject.toml index c2ab82a..67fdc4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "westquant" -version = "0.1.0a2" +version = "0.1.1" description = "WestQuant Open — Search the representation, not just the parameters" readme = "README.md" requires-python = ">=3.10" @@ -23,20 +23,20 @@ dependencies = [ ] [project.optional-dependencies] -qiskit = ["westquant-qiskit>=0.1.0a3"] +qiskit = ["westquant-qiskit>=0.1.2"] pytket = ["westquant-pytket>=0.1.0a1"] pennylane = ["westquant-pennylane>=0.1.0a1"] pulser = ["westquant-pulser>=0.1.0a1"] orchestrator = ["westquant-orchestrator>=0.1.0a1"] bridges = ["westquant-bridges>=0.1.0a1"] all = [ - "westquant-qiskit>=0.1.0a3", + "westquant-qiskit>=0.1.2", "westquant-pytket>=0.1.0a1", "westquant-pennylane>=0.1.0a1", "westquant-pulser>=0.1.0a1", "westquant-orchestrator>=0.1.0a1", ] -test = ["pytest>=8"] +test = ["pytest>=8", "westquant-qiskit>=0.1.2"] [project.scripts] westquant = "westquant.cli:main" diff --git a/src/westquant/__init__.py b/src/westquant/__init__.py index 472f264..4a119b4 100644 --- a/src/westquant/__init__.py +++ b/src/westquant/__init__.py @@ -10,7 +10,7 @@ westquant doctor # Check what's installed and working westquant compare # Compare default vs WestQuant search """ -__version__ = "0.1.0a2" +__version__ = "0.1.1" from .wqdf import WQDFSample, WQDF_VERSION, write_wqdf_jsonl, read_wqdf_jsonl, wqdf_schema from .generate import generate_training_data diff --git a/src/westquant/cli.py b/src/westquant/cli.py index 7fd8f47..ee1f54c 100644 --- a/src/westquant/cli.py +++ b/src/westquant/cli.py @@ -27,7 +27,9 @@ def _check_import(module_name: str) -> tuple[bool, str | None]: def cmd_doctor(args: argparse.Namespace) -> int: """Check what's installed and working.""" print() - print(" WestQuant Open 0.1") + from . import __version__ + + print(f" WestQuant Open {__version__}") print() print(" Component Status Version") print(" ───────────────── ─────── ───────────") @@ -121,7 +123,9 @@ def cmd_plugins(args: argparse.Namespace) -> int: def cmd_version(args: argparse.Namespace) -> int: """Show version info.""" - print("WestQuant Open 0.1.0a2") + from . import __version__ + + print(f"WestQuant Open {__version__}") print(" Schema: wqt-policy-v0.1") print(" WQIR: v0.2") print(" RepGraph: v0.2") @@ -160,12 +164,19 @@ def cmd_compare(args: argparse.Namespace) -> int: def _compare_qiskit(circuit_path: str, args: argparse.Namespace) -> int: - """Compare Qiskit default vs WestQuant search.""" + """Compare Qiskit default vs WestQuant search. + + Every row reports the equivalence status actually returned by + ``QiskitEquivalenceVerifier`` (exact, invalid or unknown); nothing is + reported as verified without that check. + """ try: from qiskit import QuantumCircuit from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager + from westquant_qiskit.search import DeterministicSearchEngine + from westquant_qiskit.verification import QiskitEquivalenceVerifier except ImportError: - print(" Qiskit not installed. Install with: pip install westquant[qiskit]") + print(" Qiskit and westquant-qiskit are required. Install with: pip install westquant[qiskit]") return 1 # Load circuit @@ -184,77 +195,68 @@ def _compare_qiskit(circuit_path: str, args: argparse.Namespace) -> int: print(f" Gates: {qc.size()}") print() - # Default compilation - opt_level = args.optimization_level or 2 - pm_default = generate_preset_pass_manager(optimization_level=opt_level) - tqc_default = pm_default.run(qc) - - default_2q = sum(1 for inst in tqc_default.data if len(inst.qubits) == 2) - default_depth = tqc_default.depth() + verifier = QiskitEquivalenceVerifier() + opt_level = 2 if args.optimization_level is None else args.optimization_level - print(f" {'':20s} {'2Q gates':>10s} {'depth':>10s} {'verified':>10s}") - print(f" {'─'*20} {'─'*10} {'─'*10} {'─'*10}") - print(f" {'Qiskit default':20s} {default_2q:>10d} {default_depth:>10d} {'yes':>10s}") + print(f" {'':20s} {'2Q gates':>10s} {'depth':>10s} {'verification':>12s}") + print(f" {'─'*20} {'─'*10} {'─'*10} {'─'*12}") - # Try higher optimization levels - for level in [3]: - if level == opt_level: - continue - pm = generate_preset_pass_manager(optimization_level=level) - tqc = pm.run(qc) + default_2q = default_depth = None + for level in dict.fromkeys([opt_level, 3]): + tqc = generate_preset_pass_manager(optimization_level=level).run(qc) gates_2q = sum(1 for inst in tqc.data if len(inst.qubits) == 2) - depth = tqc.depth() - print(f" {'Qiskit opt-' + str(level):20s} {gates_2q:>10d} {depth:>10d} {'yes':>10s}") + status = verifier.verify(qc, tqc).equivalence.value + label = f"Qiskit opt-{level}" + (" (base)" if level == opt_level else "") + print(f" {label:20s} {gates_2q:>10d} {tqc.depth():>10d} {status:>12s}") + if default_2q is None: + default_2q, default_depth = gates_2q, tqc.depth() - # WestQuant search try: - from westquant_qiskit.search import DeterministicSearchEngine - engine = DeterministicSearchEngine() - result = engine.search(qc, challenge_id="compare", max_candidates=8) - best = result.best - if best: - wq_2q = best.metrics.get("two_qubit_gates", 0) - wq_depth = best.metrics.get("depth", 0) - print(f" {'WestQuant search':20s} {wq_2q:>10d} {wq_depth:>10d} {'yes':>10s}") - print() - print(f" Candidates evaluated: {len(result.candidates)}") - print(f" Verification: EXACT") - if wq_2q < default_2q: - saving = (1 - wq_2q / default_2q) * 100 - print(f" 2Q gate reduction: {saving:.1f}%") - if wq_depth < default_depth: - saving = (1 - wq_depth / default_depth) * 100 - print(f" Depth reduction: {saving:.1f}%") - else: - print(f" {'WestQuant search':20s} {'N/A':>10s} {'N/A':>10s} {'N/A':>10s}") - except ImportError: - print(" WestQuant Qiskit plugin not installed.") - print(" Install with: pip install westquant[qiskit]") - return 1 + result = DeterministicSearchEngine().search(qc, challenge_id="compare", max_candidates=8) except Exception as e: print(f" WestQuant search error: {e}") return 1 + best = result.best + if best is None: + print(f" {'WestQuant search':20s} {'N/A':>10s} {'N/A':>10s} {'N/A':>12s}") + print() + print(f" No candidate compiled successfully out of {len(result.candidates)}.") + return 1 + wq_2q = best.metrics.get("two_qubit_gates", 0) + wq_depth = best.metrics.get("depth", 0) + report = best.verification + status = report.equivalence.value if report else "unknown" + print(f" {'WestQuant search':20s} {wq_2q:>10d} {wq_depth:>10d} {status:>12s}") + print() + print(f" Candidates evaluated: {len(result.candidates)} ({len(result.verified)} verified exact)") + print(f" Verification of selected candidate: {status}" + (f" ({report.reason})" if report and report.reason else "")) + if not (report and report.verified): + print(" Selected candidate is not verified; reductions are not reported.") + else: + if wq_2q < default_2q: + print(f" 2Q gate reduction vs opt-{opt_level}: {(1 - wq_2q / default_2q) * 100:.1f}%") + if wq_depth < default_depth: + print(f" Depth reduction vs opt-{opt_level}: {(1 - wq_depth / default_depth) * 100:.1f}%") print() return 0 +def _not_implemented(framework: str) -> int: + print(f" {framework} compare is not implemented yet. Only -f qiskit is supported.", file=sys.stderr) + return 2 + + def _compare_pytket(circuit_path: str, args: argparse.Namespace) -> int: - """Compare pytket default vs WestQuant search.""" - print(" pytket compare — coming soon") - return 0 + return _not_implemented("pytket") def _compare_pennylane(circuit_path: str, args: argparse.Namespace) -> int: - """Compare PennyLane default vs WestQuant search.""" - print(" PennyLane compare — coming soon") - return 0 + return _not_implemented("PennyLane") def _compare_pulser(circuit_path: str, args: argparse.Namespace) -> int: - """Compare Pulser default vs WestQuant search.""" - print(" Pulser compare — coming soon") - return 0 + return _not_implemented("Pulser") def cmd_generate(args: argparse.Namespace) -> int: diff --git a/tests/test_umbrella.py b/tests/test_umbrella.py index 7280b61..07f6fe7 100644 --- a/tests/test_umbrella.py +++ b/tests/test_umbrella.py @@ -4,7 +4,7 @@ def test_import(): import westquant - assert westquant.__version__ == "0.1.0a2" + assert westquant.__version__ == "0.1.1" def test_cli_import(): @@ -34,3 +34,61 @@ def test_version_runs(): args = argparse.Namespace() result = cmd_version(args) assert result == 0 + + +def _compare(tmp_path, capsys, circuit, framework="qiskit"): + import argparse + from qiskit import qasm2 + from westquant.cli import cmd_compare + + path = tmp_path / "c.qasm" + qasm2.dump(circuit, str(path)) + code = cmd_compare(argparse.Namespace(circuit=str(path), framework=framework, optimization_level=2)) + return code, capsys.readouterr() + + +def _bell(measure=False): + from qiskit import QuantumCircuit + + qc = QuantumCircuit(2) + qc.h(0) + qc.cx(0, 1) + if measure: + qc.measure_all() + return qc + + +def test_compare_reports_exact_only_from_verifier(tmp_path, capsys): + pytest.importorskip("westquant_qiskit") + code, out = _compare(tmp_path, capsys, _bell()) + assert code == 0 + assert "Verification of selected candidate: exact" in out.out + assert " yes" not in out.out + + +def test_compare_never_claims_exact_for_unverifiable_circuit(tmp_path, capsys): + pytest.importorskip("westquant_qiskit") + code, out = _compare(tmp_path, capsys, _bell(measure=True)) + assert code == 0 + assert "exact" not in out.out.lower().replace("verified exact", "") + assert "Verification of selected candidate: unknown" in out.out + assert "reductions are not reported" in out.out + + +@pytest.mark.parametrize("framework", ["pytket", "pennylane", "pulser"]) +def test_compare_unimplemented_frameworks_fail(tmp_path, capsys, framework): + pytest.importorskip("qiskit") + code, out = _compare(tmp_path, capsys, _bell(), framework) + assert code == 2 + assert "not implemented" in out.err + + +def test_readme_only_documents_exported_api(): + from pathlib import Path + import re + import westquant + + readme = (Path(__file__).resolve().parents[1] / "README.md").read_text() + for name in re.findall(r"from westquant import ([\w, ]+)", readme): + for symbol in name.split(","): + assert hasattr(westquant, symbol.strip()), symbol