diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..7da1f96 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 100 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 0fec11f..c87d8ab 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -13,7 +13,6 @@ jobs: strategy: matrix: python-version: - - "3.10" - "3.11" - "3.12" - "3.13" @@ -24,17 +23,17 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - # - name: Install dependencies - # run: >- - # python3 -m - # pip install - # .[development] - # - name: Run tests with coverage - # run: >- - # python3 -m - # pytest - # --cov=q8s - # --cov-report=xml + - name: Install dependencies + run: >- + python3 -m + pip install + .[development,qiskit,qrisp] + - name: Run tests with coverage + run: >- + python3 -m + pytest + --cov=q8s.runtime + --cov-report=xml # - name: Upload coverage to Codecov # uses: codecov/codecov-action@v5 # if: matrix.python-version == '3.10' @@ -158,4 +157,4 @@ jobs: with: repository-url: https://test.pypi.org/legacy/ skip-existing: true - verbose: true \ No newline at end of file + verbose: true diff --git a/.gitignore b/.gitignore index dfa239a..87cea00 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ mlartifacts/ mlflow.db .envrc -out.txt \ No newline at end of file +out.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cf61214 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,103 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +exclude: '^docs/conf.py' + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: check-added-large-files + - id: check-ast + # - id: check-json + - id: check-merge-conflict + - id: check-xml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: requirements-txt-fixer + - id: mixed-line-ending + args: ['--fix=auto'] # replace 'auto' with 'lf' to enforce Linux/Mac line endings or 'crlf' for Windows + +## If you want to automatically "modernize" your Python code: +# - repo: https://github.com/asottile/pyupgrade +# rev: v3.17.0 +# hooks: +# - id: pyupgrade +# args: ['--py38-plus'] + +## If you want to avoid flake8 errors due to unused vars or imports: +# - repo: https://github.com/PyCQA/autoflake +# rev: v2.3.1 +# hooks: +# - id: autoflake +# args: [ +# --in-place, +# --remove-all-unused-imports, +# --remove-unused-variables, +# ] + +- repo: https://github.com/PyCQA/docformatter + rev: v1.7.7 + hooks: + - id: docformatter + args: + - --in-place + - --wrap-summaries=88 + - --wrap-descriptions=88 + +- repo: https://github.com/PyCQA/isort + rev: 7.0.0 + hooks: + - id: isort + args: ["--profile", "black", "--filter-files"] + +- repo: https://github.com/psf/black + rev: 25.11.0 + hooks: + - id: black + language_version: python3 + +# If like to embrace black styles even in the docs: +- repo: https://github.com/asottile/blacken-docs + rev: 1.18.0 + hooks: + - id: blacken-docs + additional_dependencies: [black] + +- repo: https://github.com/PyCQA/flake8 + rev: 7.3.0 + hooks: + - id: flake8 + ## You can add flake8 plugins via `additional_dependencies`: + # additional_dependencies: [flake8-bugbear] + +## Check for misspells in documentation files: +# - repo: https://github.com/codespell-project/codespell +# rev: v2.3.0 +# hooks: +# - id: codespell + +- repo: https://github.com/google/addlicense + rev: v1.2.0 + hooks: + - id: addlicense + args: ["-c", "Qubernetes Project", "-s", "*.py"] + # - -c + # - Qubernetes Project + # - -l + # - apache + # - "*.py" + files: ^(src|tests)/.*\.py$ diff --git a/README.md b/README.md index 01a08ab..ff0594c 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,39 @@ # q8s.runtime -A runtime library for q8s workloads. +`q8s.runtime` provides common runtime, provenance, and experiment-tracking capabilities for quantum software. + +The library provides a QDK-independent representation of quantum programs and their execution metadata, together with integrations for quantum development kits such as [Qiskit](https://www.ibm.com/quantum/qiskit) and [Qrisp](https://www.qrisp.eu/index.html). This makes it possible to collect and analyse execution and compilation information consistently across different quantum software stacks. ## Installation -You can install the q8s.runtime library using pip: +Install the core package using: ```bash pip install q8s.runtime ``` -## Features +Support for individual quantum development kits can be installed using the corresponding optional dependencies: + +```bash +pip install "q8s.runtime[qiskit]" +pip install "q8s.runtime[qrisp]" +``` + +Multiple integrations can be installed together: + +```bash +pip install "q8s.runtime[qiskit,qrisp]" +``` + +## Integrations + +### Qiskit + +The Qiskit integration provides adapters for Qiskit objects and automatic experiment tracking through MLflow. -### Qiskit autologging to MLflow +#### MLflow autologging + +Qiskit transpilation and execution can be automatically captured by enabling autologging: ```python import mlflow @@ -48,3 +69,121 @@ with mlflow.start_run(): result.get_counts() ``` + +The integration can capture information about the transpilation process, including individual transpiler passes and their associated metadata. + +### Qrisp + +The Qrisp integration converts Qrisp programs into the common `q8s.runtime` representation, allowing provenance and experiment information produced by Qrisp workflows to be handled using the same model as Qiskit workflows. + +```python +import mlflow + +from q8s.runtime.mlflow.qrisp import autolog + +autolog() + +from qrisp import ( + PassManager, + QuantumCircuit, + combine_single_qubit_gates, + commute_swaps, + fuse_adjacents, +) + +from q8s.runtime.mlflow.qrisp.autologging import get_context +from q8s.runtime.qprov.graphs import plot_transpilation_timeline + +mlflow.set_experiment("qrisp-transpilation") + +with mlflow.start_run(): + + qc = QuantumCircuit(2) + qc.cx(0, 1) + qc.cx(0, 1) # Self-inverse — will be cancelled + qc.h(0) + qc.h(0) # Another self-inverse pair + + pm = PassManager() + pm += fuse_adjacents + pm += commute_swaps + pm += combine_single_qubit_gates + + optimized_qc = pm.run(qc) +``` + +## Capabilities + +### Provenance + +`q8s.runtime` uses the **QProv provenance model** to describe the information associated with the lifecycle of a quantum program. + +QProv organizes provenance information into four main categories: + +| QProv category | Description | Qiskit | Qrisp | +| -------------------- | ----------------------------------------------------------------- | :----: | :---: | +| **Quantum Circuit** | Structure and characteristics of the quantum circuit | ◐ | ◐ | +| **Quantum Computer** | Characteristics of the quantum computer or execution backend | - | - | +| **Compilation** | Transformation of a quantum circuit for a target quantum computer | ◐ | ◐ | +| **Execution** | Information associated with executing the compiled circuit | - | - | + +The availability of individual provenance attributes depends on the QDK, backend, provider, and application. + +### Quantum Circuit + +Quantum Circuit provenance describes the structure and characteristics of the quantum circuit being executed. + +| QProv | Provenance attribute | Qiskit | Qrisp | +| ------ | -------------------- | :----: | :---: | +| **Q1** | Gates | ✓ | ✓ | +| **Q2** | Measurements | ✓ | ✓ | +| **Q3** | Execution order | - | - | +| **Q4** | Circuit width | ✓ | ✓ | +| **Q5** | Circuit depth | ✓ | ✓ | +| **Q6** | Circuit size | ✓ | ✓ | +| **Q7** | Encoding | - | - | + +Circuit width represents the number of qubits used by the circuit, circuit depth describes the number of sequential operations required by the circuit, and circuit size describes its number of operations. + +### Compilation + +Compilation provenance describes how an abstract quantum circuit is transformed into a circuit that can be executed by a particular quantum computer. + +| QProv | Provenance attribute | Qiskit | Qrisp | +| ------ | -------------------- | :----: | :---: | +| **C1** | Qubit assignments | ✓ | ✓ | +| **C2** | Gate mappings | ✓ | ✓ | +| **C3** | Optimisation goal | ✓ | - | +| **C4** | Random seed | ✓ | - | +| **C5** | Compilation time | ✓ | ✓ | + +In addition to the QProv compilation attributes, `q8s.runtime` toolkit collects **fine-grained compiler provenance**. + +For each transpiler pass, the following information can be recorded: + +| Compiler provenance | Description | Qiskit | Qrisp | +| ------------------- | ------------------------------------------------- | :----: | :---: | +| **Pass index** | Position of the pass in the transpilation process | ✓ | ✓ | +| **Pass name** | Transpiler pass name | ✓ | ✓ | +| **Stage** | Stage of the staged pass manager | ✓ | - | +| **Duration** | Execution time of the pass | ✓ | ✓ | +| **Circuit depth** | Circuit depth after the pass | ✓ | ✓ | +| **Circuit size** | Circuit size after the pass | ✓ | ✓ | + +This extends QProv's compilation provenance with information about the internal compilation process and enables reconstruction and visualization of a **transpilation timeline**. + +**Legend:** ✓ supported · ◐ dependent on QDK/backend/application · — not currently collected + +## References + +The provenance model implemented by `q8s.runtime` is based on: + +> Weder, B., Breitenbücher, U., Leymann, F., and Wild, K. +> _Integrating quantum computing into workflow modeling and execution._ +> IET Quantum Communication. + +See the QProv publication for the complete provenance model and definitions. + +## License + +`q8s.runtime` is licensed under the Apache License 2.0. diff --git a/pyproject.toml b/pyproject.toml index 1b2a816..faeb851 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,7 @@ name = "q8s.runtime" version = "0.1.0" authors = [{ name = "Vlad Stirbu", email = "vstirbu@gmail.com" }] description = "A runtime library for q8s workloads" +readme = "README.md" requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", @@ -15,7 +16,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -28,6 +28,11 @@ dependencies = ["mlflow-skinny", "dataclasses-json"] [project.optional-dependencies] matplotlib = ["matplotlib"] +development = ["pre-commit", "pytest", "pytest-cov"] + +qiskit = ["qiskit", "qiskit-aer"] +qrisp = ["qrisp"] + test = ["mqt.bench", "qiskit<2.2", "mlflow", "iqm-client[qiskit]"] [build-system] requires = ["setuptools >= 61.0"] @@ -35,3 +40,7 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] diff --git a/src/q8s/runtime/__init__.py b/src/q8s/runtime/__init__.py index 387d5ae..5f0b272 100644 --- a/src/q8s/runtime/__init__.py +++ b/src/q8s/runtime/__init__.py @@ -1,3 +1,19 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + from importlib.metadata import version __version__ = version("q8s.runtime") diff --git a/src/q8s/runtime/matplotlib/backend.py b/src/q8s/runtime/matplotlib/backend.py index beb4163..a7ebb48 100644 --- a/src/q8s/runtime/matplotlib/backend.py +++ b/src/q8s/runtime/matplotlib/backend.py @@ -1,3 +1,19 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + import base64 import io @@ -41,4 +57,4 @@ def show(): # Register the custom show function -matplotlib.backend_bases.show = show \ No newline at end of file +matplotlib.backend_bases.show = show diff --git a/src/q8s/runtime/mlflow/qiskit/__init__.py b/src/q8s/runtime/mlflow/qiskit/__init__.py index 4cd53ad..d0b96fe 100644 --- a/src/q8s/runtime/mlflow/qiskit/__init__.py +++ b/src/q8s/runtime/mlflow/qiskit/__init__.py @@ -1 +1,20 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +"""MLflow autologging integration for Qiskit.""" + from q8s.runtime.mlflow.qiskit.autologging import autolog + +__all__ = ["autolog"] diff --git a/src/q8s/runtime/mlflow/qiskit/autologging.py b/src/q8s/runtime/mlflow/qiskit/autologging.py index 4e90818..629f22d 100644 --- a/src/q8s/runtime/mlflow/qiskit/autologging.py +++ b/src/q8s/runtime/mlflow/qiskit/autologging.py @@ -1,16 +1,32 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + import contextvars import tempfile import time from pathlib import Path -from mlflow.tracking.fluent import ActiveRun +import mlflow import qiskit +from mlflow.tracking.fluent import ActiveRun +from mlflow.utils.autologging_utils import autologging_integration, safe_patch from qiskit.circuit import QuantumCircuit from qiskit.transpiler import StagedPassManager from qiskit_aer import AerSimulator -from mlflow.utils.autologging_utils import autologging_integration, safe_patch - from q8s.runtime.mlflow.qiskit.transpiler import ( find_stage_by_id, process_staged_pass_manager, @@ -49,10 +65,10 @@ def autolog( silent=False, extra_tags=None, ) -> None: - """ - Enables the Qiskit autologging integration. + """Enables the Qiskit autologging integration. - This function is idempotent and can be called multiple times. The integration will only be enabled once. + This function is idempotent and can be called multiple times. The integration will + only be enabled once. """ def patched_staged_pass_manager_run( @@ -116,13 +132,22 @@ def patched_generate_preset_pass_manager( *args, **kwargs, ): - """ - Patch the generate_preset_pass_manager function to log the backend information to the QProvRecord. - """ + """Patch the generate_preset_pass_manager function to log the backend + information to the QProvRecord.""" + print( "Qiskit autologging integration is enabled for generate_preset_pass_manager." ) + run = mlflow.active_run() + + if run is None: + # noqa: E501 + raise RuntimeError( + """No active MLflow run. `generate_preset_pass_manager` must be + called within an active MLflow run.""" + ) + ctx = _current_context.get() if ctx is None or not isinstance(ctx, QProvRecord): @@ -173,7 +198,7 @@ def patched_result(original_result, job_self, *r_args, **r_kwargs): # log result here try: - counts = result.get_counts() + result.get_counts() # print("counts:", counts) # mlflow.log_dict(counts, "qiskit/result_counts.json") except Exception: @@ -198,9 +223,8 @@ def patched_result(original_result, job_self, *r_args, **r_kwargs): ) def patched_activerun_exit(original, *args, **kwargs): - """ - Patch the __exit__ method of ActiveRun to log the QProvRecord to MLflow when the run ends. - """ + """Patch the __exit__ method of ActiveRun to log the QProvRecord to MLflow when + the run ends.""" ctx = _current_context.get() if ctx is None or not isinstance(ctx, QProvRecord): @@ -223,9 +247,7 @@ def patched_activerun_exit(original, *args, **kwargs): def callback(pass_, dag, time, property_set, count): - """ - Callback function for logging pass information during transpilation. - """ + """Callback function for logging pass information during transpilation.""" name = pass_.__class__.__name__ ctx = _current_context.get() @@ -250,10 +272,7 @@ def callback(pass_, dag, time, property_set, count): def log_to_mlflow(record: QProvRecord): - """ - Logs the QProvRecord to MLflow. - """ - import mlflow + """Logs the QProvRecord to MLflow.""" with tempfile.TemporaryDirectory() as tmpdir: tmp = Path(tmpdir) @@ -269,17 +288,10 @@ def log_to_mlflow(record: QProvRecord): if run is None: raise RuntimeError("No active MLflow run. Please start a run first.") - ctx = _current_context.get() - - if ctx is None or not isinstance(ctx, QProvRecord): - raise RuntimeError( - "No active autolog context. Please call transpile() first." - ) - - passes = sorted(ctx.compilation.passes, key=lambda p: p.pass_index) + passes = sorted(record.compilation.passes, key=lambda p: p.pass_index) mlflow.log_metric("passes_count", len(passes)) - mlflow.log_metric("transpilation_duration", ctx.compilation.duration_s) + mlflow.log_metric("transpilation_duration", record.compilation.duration_s) mlflow.log_metric( "circuit_depth", passes[-1].pass_metadata.get("depth", 0) if passes else 0 ) diff --git a/src/q8s/runtime/mlflow/qiskit/transpiler.py b/src/q8s/runtime/mlflow/qiskit/transpiler.py index b6d07ce..9b6fdaf 100644 --- a/src/q8s/runtime/mlflow/qiskit/transpiler.py +++ b/src/q8s/runtime/mlflow/qiskit/transpiler.py @@ -1,9 +1,25 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +from qiskit.passmanager import FlowControllerLinear, GenericPass from qiskit.transpiler import ( ConditionalController, DoWhileController, StagedPassManager, ) -from qiskit.passmanager import FlowControllerLinear, GenericPass def process_pass( @@ -12,13 +28,15 @@ def process_pass( ), stage_info: list[int], ): - """ - Recursively process a pass or controller and collect the IDs of all passes in the stage_info list. + """Recursively process a pass or controller and collect the IDs of all passes in the + stage_info list. + Args: task: The pass or controller to process. stage_info: A list to collect the IDs of all passes. Returns: - The updated stage_info list containing the IDs of all passes.""" + The updated stage_info list containing the IDs of all passes. + """ if ( isinstance(task, ConditionalController) or isinstance(task, DoWhileController) @@ -36,8 +54,9 @@ def process_pass( def process_staged_pass_manager(manager: StagedPassManager) -> dict[str, list[int]]: - """ - Process a StagedPassManager and return a dictionary containing the stage names and their corresponding pass IDs. + """Process a StagedPassManager and return a dictionary containing the stage names + and their corresponding pass IDs. + Args: manager (StagedPassManager): The StagedPassManager to process. Returns: @@ -56,8 +75,8 @@ def process_staged_pass_manager(manager: StagedPassManager) -> dict[str, list[in def find_stage_by_id(stage_pass_info: dict[str, list[int]], pass_id: int) -> str | None: - """ - Find the stage name corresponding to a given pass ID in the staged pass manager. + """Find the stage name corresponding to a given pass ID in the staged pass manager. + Args: stage_pass_info (dict): A dictionary mapping stage names to lists of pass IDs. pass_id (int): The ID of the pass to find. diff --git a/src/q8s/runtime/mlflow/qrisp/__init__.py b/src/q8s/runtime/mlflow/qrisp/__init__.py new file mode 100644 index 0000000..601986f --- /dev/null +++ b/src/q8s/runtime/mlflow/qrisp/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +"""MLflow autologging integration for Qrisp.""" + +from q8s.runtime.mlflow.qrisp.autologging import autolog + +__all__ = ["autolog"] diff --git a/src/q8s/runtime/mlflow/qrisp/autologging.py b/src/q8s/runtime/mlflow/qrisp/autologging.py new file mode 100644 index 0000000..8af8eb9 --- /dev/null +++ b/src/q8s/runtime/mlflow/qrisp/autologging.py @@ -0,0 +1,136 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +import contextvars +import time +from importlib.metadata import version + +from mlflow import ActiveRun +from mlflow.utils.autologging_utils import autologging_integration, safe_patch +from qrisp import PassManager, QuantumCircuit + +from q8s.runtime.mlflow.qiskit.autologging import log_to_mlflow +from q8s.runtime.qprov.record import ( + CompilationProvenance, + QProvRecord, + QuantumCircuitProvenance, +) + +CONTEXT_NAME = "qrisp_mlflow_autologging_context" +_current_context: contextvars.ContextVar[QProvRecord] = contextvars.ContextVar( + CONTEXT_NAME, + default=QProvRecord( + compilation=CompilationProvenance( + compiler="qrisp", compiler_version=version("qrisp") + ) + ), +) + + +def get_context() -> QProvRecord: + ctx = _current_context.get() + if ctx is None: + raise RuntimeError("No active autolog context") + return ctx + + +@autologging_integration("qrisp") +def autolog( + disable=False, + silent=False, +) -> None: + """Enables autologging for QRISPs.""" + + if disable: + return + + def patched_pass_manager_run( + original, # type: ignore[no-untyped-def] + instance: PassManager, + *args, + **kwargs, + ): + """Patched version of PassManager.run that logs the execution of passes and + their provenance.""" + + print("QRISPs autologging integration is enabled for PassManager.run.") + + ctx = _current_context.get() + + circuit: QuantumCircuit = args[0] + + ctx.circuit = QuantumCircuitProvenance( + circuit_id=str(id(circuit)), + num_qubits=circuit.num_qubits(), + depth=circuit.depth(), + width=len(circuit.qubits), + gate_counts=circuit.count_ops(), + ) + + result: QuantumCircuit = circuit + + start = time.perf_counter() + + # history = original(instance, *args, **kwargs) + + end = time.perf_counter() + + for index, circuit_pass in enumerate(instance._passes): + pass_start = time.perf_counter() + + result = circuit_pass(result) + + pass_end = time.perf_counter() + + ctx.compilation.add_pass( + pass_name=circuit_pass.__name__, + pass_index=index, + pass_duration_s=pass_end - pass_start, + pass_metadata={ + "depth": result.depth(), + "size": sum(result.count_ops().values()), + "width": len(result.qubits), + }, + ) + + ctx.compilation.duration_s = end - start + + return result + + safe_patch("qrisp", PassManager, "run", patched_pass_manager_run) + + def patched_activerun_exit(original, *args, **kwargs): + """Patch the __exit__ method of ActiveRun to log the QProvRecord to MLflow when + the run ends.""" + ctx = _current_context.get() + + if ctx is None or not isinstance(ctx, QProvRecord): + raise RuntimeError( + "No active autolog context. Please call transpile() first." + ) + + log_to_mlflow(ctx) + + return original(*args, **kwargs) + + safe_patch( + "qrisp", + ActiveRun, + "__exit__", + patched_activerun_exit, + manage_run=False, + # extra_tags=extra_tags, + ) diff --git a/src/q8s/runtime/qprov/graphs.py b/src/q8s/runtime/qprov/graphs.py index 8e81ad4..05e5aa7 100644 --- a/src/q8s/runtime/qprov/graphs.py +++ b/src/q8s/runtime/qprov/graphs.py @@ -1,10 +1,25 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + import matplotlib.pyplot as plt from matplotlib import colormaps def plot_transpilation_timeline(passes, figsize=(16, 8)): - """ - Plot a transpilation timeline. + """Plot a transpilation timeline. Parameters ---------- diff --git a/src/q8s/runtime/qprov/record.py b/src/q8s/runtime/qprov/record.py index 1da37f5..7241099 100644 --- a/src/q8s/runtime/qprov/record.py +++ b/src/q8s/runtime/qprov/record.py @@ -1,6 +1,22 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + from __future__ import annotations -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from typing import Any from uuid import uuid4 @@ -106,6 +122,27 @@ class ExecutionProvenance: @dataclass_json @dataclass class QProvRecord: + """A record of the provenance of a quantum circuit execution, including information + about the circuit, the quantum computer, the compilation process, and the execution + results. + + Attributes: + circuit : QuantumCircuitProvenance | None + The provenance information of the quantum circuit. + quantum_computer : QuantumComputerProvenance | None + The provenance information of the quantum computer used for execution. + compilation : CompilationProvenance | None + The provenance information of the compilation process. + execution : ExecutionProvenance | None + The provenance information of the execution results. + created_at : datetime + The timestamp when the record was created. + record_id : str + A unique identifier for the record. + metadata : dict[str, Any] + Additional metadata associated with the record. + """ + circuit: QuantumCircuitProvenance | None = None quantum_computer: QuantumComputerProvenance | None = None compilation: CompilationProvenance | None = None diff --git a/src/q8s/runtime/tracking.py b/src/q8s/runtime/tracking.py deleted file mode 100644 index 6df66df..0000000 --- a/src/q8s/runtime/tracking.py +++ /dev/null @@ -1,133 +0,0 @@ -import time -import json -import tempfile -from pathlib import Path -from dataclasses import dataclass -from typing import Any - -import mlflow -from qiskit import QuantumCircuit -from qiskit.transpiler import StagedPassManager, generate_preset_pass_manager - -from q8s.runtime.mlflow.qiskit.autologging import get_context - - -@dataclass -class MLflowTranspilationManager: - experiment_name: str = "qiskit-transpilation" - optimization_level: int = 1 - backend: Any | None = None - basis_gates: list[str] | None = None - coupling_map: Any | None = None - - def __post_init__(self): - mlflow.set_experiment(self.experiment_name) - - def transpile( - self, circuit: QuantumCircuit, run_name: str | None = None - ) -> QuantumCircuit: - with mlflow.start_run(run_name=run_name): - self._log_input(circuit) - - mlflow.log_params( - { - "optimization_level": self.optimization_level, - "backend": getattr(self.backend, "name", None) or str(self.backend), - "basis_gates": json.dumps(self.basis_gates), - "num_qubits_input": circuit.num_qubits, - } - ) - - pass_manager: StagedPassManager = generate_preset_pass_manager( - optimization_level=self.optimization_level, - backend=self.backend, - basis_gates=self.basis_gates, - coupling_map=self.coupling_map, - ) - - start = time.perf_counter() - transpiled = pass_manager.run(circuit) - elapsed = time.perf_counter() - start - - self._log_output(circuit, transpiled, elapsed) - self._log_artifacts(circuit, transpiled) - - return transpiled - - def _log_input(self, circuit: QuantumCircuit): - mlflow.log_metrics( - { - "input_depth": circuit.depth(), - "input_size": circuit.size(), - "input_width": circuit.width(), - "input_num_qubits": circuit.num_qubits, - "input_num_clbits": circuit.num_clbits, - } - ) - - for gate, count in circuit.count_ops().items(): - mlflow.log_metric(f"input_gate_{gate}", count) - - def _log_output( - self, - original: QuantumCircuit, - transpiled: QuantumCircuit, - elapsed: float, - ): - mlflow.log_metrics( - { - "transpilation_time_s": elapsed, - "output_depth": transpiled.depth(), - "output_size": transpiled.size(), - "output_width": transpiled.width(), - "output_num_qubits": transpiled.num_qubits, - "output_num_clbits": transpiled.num_clbits, - "depth_delta": transpiled.depth() - original.depth(), - "size_delta": transpiled.size() - original.size(), - } - ) - - for gate, count in transpiled.count_ops().items(): - mlflow.log_metric(f"output_gate_{gate}", count) - - def _log_artifacts(self, original: QuantumCircuit, transpiled: QuantumCircuit): - with tempfile.TemporaryDirectory() as tmpdir: - tmp = Path(tmpdir) - - input_qasm = tmp / "input_circuit.qasm" - output_qasm = tmp / "transpiled_circuit.qasm" - summary = tmp / "transpilation_summary.json" - qprov = tmp / "qprov_record.json" - - try: - input_qasm.write_text(original.qasm()) - output_qasm.write_text(transpiled.qasm()) - except AttributeError as e: - from qiskit.qasm3 import dumps - - input_qasm.write_text(dumps(original)) - output_qasm.write_text(dumps(transpiled)) - - summary.write_text( - json.dumps( - { - "input": { - "depth": original.depth(), - "size": original.size(), - "count_ops": dict(original.count_ops()), - }, - "output": { - "depth": transpiled.depth(), - "size": transpiled.size(), - "count_ops": dict(transpiled.count_ops()), - }, - }, - indent=2, - ) - ) - - ctx = get_context() - - qprov.write_text(ctx.to_json(indent=2, sort_keys=True)) - - mlflow.log_artifacts(tmp) diff --git a/test-mlflow.py b/test-mlflow.py index fbf5c8e..4355584 100644 --- a/test-mlflow.py +++ b/test-mlflow.py @@ -1,5 +1,8 @@ +# flake8: noqa: E402 + import mlflow from mqt.bench import BenchmarkLevel, get_benchmark + from q8s.runtime.mlflow.qiskit import autolog autolog() @@ -17,12 +20,12 @@ backend = IQMFakeAphrodite() -manager = generate_preset_pass_manager( - optimization_level=3, - backend=backend, -) with mlflow.start_run(): + manager = generate_preset_pass_manager( + optimization_level=3, + backend=backend, + ) tqc = manager.run(qc) diff --git a/test-qrisp.py b/test-qrisp.py new file mode 100644 index 0000000..187327a --- /dev/null +++ b/test-qrisp.py @@ -0,0 +1,56 @@ +# flake8: noqa: E402 + +import mlflow + +from q8s.runtime.mlflow.qrisp import autolog + +autolog() + +from iqm.qiskit_iqm.fake_backends.fake_aphrodite import IQMFakeAphrodite +from iqm.qrisp_iqm import create_iqm_pass_manager +from qrisp import ( + Clbit, + QuantumCircuit, + Qubit, +) + + +def build_demo_circuit() -> QuantumCircuit: + """Build a small demo circuit with 2-qubit interactions. + + https://docs.iqm.tech/iqm-client/user_guide_qrisp/plasma_sabre_tutorial.html#demo-circuit. + """ + qc = QuantumCircuit() + + # Give logical qubits distinctive names + for i in range(4): + qc.add_qubit(Qubit("original_qb_" + str(i))) + + for i in range(4): + qc.add_clbit(Clbit("c" + str(i))) + + qc.h(0) + qc.cx(0, 1) + qc.ry(0.7, 2) + qc.cz(1, 2) + qc.cx(2, 3) + qc.s(1) + qc.cy(0, 2) + + # Add measurements for execution workflows + qc.measure(qc.qubits, qc.clbits) + return qc + + +mlflow.set_experiment("qrisp-transpilation") + +backend = IQMFakeAphrodite() + +connectivity = backend.target.build_coupling_map() + +with mlflow.start_run(): + qc = build_demo_circuit() + + pm = create_iqm_pass_manager(connectivity=connectivity) + + optimized_qc = pm.run(qc) diff --git a/test.py b/test.py deleted file mode 100644 index f4d3ce8..0000000 --- a/test.py +++ /dev/null @@ -1,28 +0,0 @@ -import q8s.runtime -from mqt.bench import BenchmarkLevel, get_benchmark - -from iqm.qiskit_iqm.fake_backends.fake_aphrodite import IQMFakeAphrodite - -from q8s.runtime.mlflow.qiskit import autolog - -autolog() - -from q8s.runtime.tracking import MLflowTranspilationManager - -print("q8s.runtime version:", q8s.runtime.__version__) - -qc = get_benchmark( - benchmark="ghz", - level=BenchmarkLevel.ALG, - circuit_size=12, -) - -backend = IQMFakeAphrodite() - -manager = MLflowTranspilationManager( - experiment_name="qubernetes-transpilation", - optimization_level=1, - backend=backend, -) - -tqc = manager.transpile(qc, run_name="qft-8-opt3") diff --git a/tests/test_qrisp_autologging.py b/tests/test_qrisp_autologging.py new file mode 100644 index 0000000..ec81bb0 --- /dev/null +++ b/tests/test_qrisp_autologging.py @@ -0,0 +1,109 @@ +# Copyright 2026 Qubernetes Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +import unittest +from unittest.mock import patch + +from qrisp import PassManager, QuantumCircuit + +import q8s.runtime.mlflow.qrisp.autologging as autologging +from q8s.runtime.qprov.record import CompilationProvenance, QProvRecord + +original_run = PassManager.run + + +class TestAutolog(unittest.TestCase): + def _patched_run(self): + with patch.object(autologging, "safe_patch") as safe_patch: + autologging.autolog(disable=False) + + safe_patch.assert_called_once() + return safe_patch.call_args.args[3] + + def _make_circuit(self): + """Creates a simple quantum circuit for testing. + + Returns: + QuantumCircuit: A simple quantum circuit with 2 qubits and 4 gates. + ┌───┐┌───┐ + qb_58: ──■────■──┤ H ├┤ H ├ + ┌─┴─┐┌─┴─┐└───┘└───┘ + qb_59: ┤ X ├┤ X ├────────── + └───┘└───┘ + """ + from qrisp import QuantumCircuit + + qc = QuantumCircuit(2) + qc.cx(0, 1) + qc.cx(0, 1) # Self-inverse — will be cancelled + qc.h(0) + qc.h(0) # Another self-inverse pair + + return qc + + def _make_pass_manager(self): + from qrisp import ( + PassManager, + combine_single_qubit_gates, + commute_swaps, + fuse_adjacents, + ) + + pm = PassManager() + pm += fuse_adjacents + pm += commute_swaps + pm += combine_single_qubit_gates + + return pm + + def test_disabled_autolog_does_not_install_patch(self): + with patch.object(autologging, "safe_patch") as safe_patch: + autologging.autolog(disable=True) + + safe_patch.assert_not_called() + + def test_enabled_autolog_installs_patch(self): + with patch.object(autologging, "safe_patch") as safe_patch: + autologging.autolog(disable=False) + + self.assertEqual(safe_patch.call_count, 2) + + def test_patch_records_circuit_and_compilation_duration(self): + + try: + autologging.autolog(disable=False) + + circuit = self._make_circuit() + pass_manager = self._make_pass_manager() + context = QProvRecord(compilation=CompilationProvenance(compiler="qrisp")) + token = autologging._current_context.set(context) + + result = pass_manager.run(circuit) + autologging._current_context.reset(token) + finally: + PassManager.run = original_run + + self.assertIsInstance(result, QuantumCircuit) + self.assertEqual(context.circuit.circuit_id, str(id(circuit))) + self.assertEqual(context.circuit.num_qubits, 2) + self.assertEqual(context.circuit.depth, 4) + self.assertEqual(context.circuit.width, 2) + self.assertEqual(context.circuit.gate_counts, {"h": 2, "cx": 2}) + self.assertEqual(len(context.compilation.passes), 3) + + +if __name__ == "__main__": + unittest.main()