diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d8ef2af6..84f52005 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -61,7 +61,7 @@ jobs: pip install --upgrade pip pip install setuptools pip install -r requirements-test.txt - bin/install_wheel_extras.sh dist --extra qasm3 --extra cirq --extra squin + bin/install_wheel_extras.sh dist --extra qasm3 --extra cirq --extra qiskit --extra squin - name: Run tests with pyqir 0.11.x (typed pointers) run: | pip install "pyqir>=0.10.0,<0.12" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a78a54e..94129eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Types of changes: ### ➕ New Features +- Added `qbraid_qir.qiskit` module for Qiskit to QIR conversion, ported from the archived [microsoft/qiskit-qir](https://github.com/microsoft/qiskit-qir) repository (MIT License). The module has been updated for compatibility with Qiskit 2.x and follows qbraid-qir conventions. Main entry point is `qiskit_to_qir(circuit, name=None, **kwargs)` which converts a Qiskit `QuantumCircuit` to a PyQIR `Module`. ([#272](https://github.com/qBraid/qbraid-qir/issues/272), [#271](https://github.com/qBraid/qbraid-qir/pull/271)) + ### 🌟 Improvements ### 📜 Documentation diff --git a/README.md b/README.md index a937bc4d..770609af 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ For Cirq to QIR conversions, install the `cirq` extra: pip install 'qbraid-qir[cirq]' ``` +For Qiskit to QIR conversions, install the `qiskit` extra: + +```shell +pip install 'qbraid-qir[qiskit]' +``` + For QIR to SQUIN conversions, install the `squin` extra: ```shell @@ -146,6 +152,22 @@ module = cirq_to_qir(circuit, name="my-circuit") ir = str(module) ``` +### Qiskit conversions + +```python +from qiskit import QuantumCircuit +from qbraid_qir import qiskit_to_qir + +circuit = QuantumCircuit(2, 2) +circuit.h(0) +circuit.cx(0, 1) +circuit.measure([0, 1], [0, 1]) + +module = qiskit_to_qir(circuit, name="bell") + +ir = str(module) +``` + ### SQUIN conversions ```python diff --git a/docs/api/qbraid_qir.qiskit.rst b/docs/api/qbraid_qir.qiskit.rst new file mode 100644 index 00000000..a73f969e --- /dev/null +++ b/docs/api/qbraid_qir.qiskit.rst @@ -0,0 +1,8 @@ +:orphan: + +qbraid_qir.qiskit +================== + +.. automodule:: qbraid_qir.qiskit + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py index 185680bd..155a2c21 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,7 +41,7 @@ # set_type_checking_flag = True autodoc_member_order = "bysource" autoclass_content = "both" -autodoc_mock_imports = ["cirq", "openqasm3", "pyqasm", "numpy", "numpy.typing", "kirin", "bloqade"] +autodoc_mock_imports = ["cirq", "openqasm3", "pyqasm", "numpy", "numpy.typing", "kirin", "bloqade", "qiskit"] napoleon_numpy_docstring = False todo_include_todos = True mathjax_path = "https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS-MML_HTMLorMML" diff --git a/docs/index.rst b/docs/index.rst index 977cd951..4831809c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -81,11 +81,11 @@ qBraid-QIR requires Python 3.10 or greater. The base package can be installed wi pip install qbraid-qir -To enable specific conversions such as OpenQASM 3 to QIR or Cirq to QIR, you can install one or both extras: +To enable specific conversions such as OpenQASM 3 to QIR, Cirq to QIR, or Qiskit to QIR, you can install the extras: .. code-block:: bash - pip install 'qbraid-qir[qasm3,cirq]' + pip install 'qbraid-qir[qasm3,cirq,qiskit]' Resources @@ -117,6 +117,7 @@ Resources api/qbraid_qir api/qbraid_qir.cirq api/qbraid_qir.qasm3 + api/qbraid_qir.qiskit api/qbraid_qir.squin .. toctree:: diff --git a/pyproject.toml b/pyproject.toml index 68e0a876..b4a391bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "qBraid-SDK extension providing support for QIR conversions." readme = "README.md" authors = [{name = "qBraid Development Team"}, {email = "contact@qbraid.com"}] license = "Apache-2.0" -keywords = ["qbraid", "quantum", "qir", "llvm", "cirq", "openqasm", "squin"] +keywords = ["qbraid", "quantum", "qir", "llvm", "cirq", "openqasm", "qiskit", "squin"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", @@ -40,10 +40,11 @@ Discord = "https://discord.gg/TPBU2sa8Et" [project.optional-dependencies] cirq = ["cirq-core>=1.3.0,<1.6.0"] qasm3 = ["pyqasm>=0.4.0,<1.1.0", "numpy"] +qiskit = ["qiskit>=2.0,<3.0"] squin = ["kirin-toolchain>=0.17.33", "bloqade-circuit>=0.9.1"] [tool.setuptools] -packages = ["qbraid_qir", "qbraid_qir.cirq", "qbraid_qir.qasm3", "qbraid_qir.squin"] +packages = ["qbraid_qir", "qbraid_qir.cirq", "qbraid_qir.qasm3", "qbraid_qir.qiskit", "qbraid_qir.squin"] [tool.setuptools.dynamic] version = {attr = "qbraid_qir.__version__"} diff --git a/qbraid_qir/__init__.py b/qbraid_qir/__init__.py index 31c670ad..c0e6ad69 100644 --- a/qbraid_qir/__init__.py +++ b/qbraid_qir/__init__.py @@ -51,13 +51,15 @@ "dumps", "qasm3_to_qir", "cirq_to_qir", + "qiskit_to_qir", ] -_lazy = {"cirq": "cirq_to_qir", "qasm3": "qasm3_to_qir"} +_lazy = {"cirq": ("cirq_to_qir",), "qasm3": ("qasm3_to_qir",), "qiskit": ("qiskit_to_qir",)} if TYPE_CHECKING: from .cirq import cirq_to_qir from .qasm3 import qasm3_to_qir + from .qiskit import qiskit_to_qir def __getattr__(name): diff --git a/qbraid_qir/qiskit/NOTICE.md b/qbraid_qir/qiskit/NOTICE.md new file mode 100644 index 00000000..abeb8355 --- /dev/null +++ b/qbraid_qir/qiskit/NOTICE.md @@ -0,0 +1,37 @@ +# Third Party Notices + +This software includes code derived from third party sources. + +## qiskit-qir + +The `qbraid_qir.qiskit` module contains code derived from [microsoft/qiskit-qir](https://github.com/microsoft/qiskit-qir). + +``` +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE +``` + +The qiskit-qir codebase has been adapted to: +- Work with Qiskit 2.x (updated for new QuantumCircuit API) +- Follow qbraid-qir conventions and coding style +- Support both pyqir 0.10.x and 0.12+ (typed and opaque pointers) +- Integrate with the qbraid-qir module structure diff --git a/qbraid_qir/qiskit/__init__.py b/qbraid_qir/qiskit/__init__.py new file mode 100644 index 00000000..8430e1f1 --- /dev/null +++ b/qbraid_qir/qiskit/__init__.py @@ -0,0 +1,59 @@ +# Copyright 2026 qBraid +# +# 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. + +# pylint: disable=line-too-long +# Portions of this module are adapted from microsoft/qiskit-qir +# (https://github.com/microsoft/qiskit-qir), with modifications by qBraid. +# The original MIT license notice is reproduced in NOTICE.md. +# pylint: enable=line-too-long + +""" +Module containing Qiskit QIR functionality. + +.. currentmodule:: qbraid_qir.qiskit + +Functions +----------- + +.. autosummary:: + :toctree: ../stubs/ + + qiskit_to_qir + + +Classes +--------- + +.. autosummary:: + :toctree: ../stubs/ + + QiskitModule + BasicQiskitVisitor + +Exceptions +----------- + +.. autosummary:: + :toctree: ../stubs/ + + QiskitConversionError + +""" + +from .convert import qiskit_to_qir +from .elements import QiskitModule +from .exceptions import QiskitConversionError +from .visitor import BasicQiskitVisitor + +__all__ = ["qiskit_to_qir", "QiskitModule", "QiskitConversionError", "BasicQiskitVisitor"] diff --git a/qbraid_qir/qiskit/convert.py b/qbraid_qir/qiskit/convert.py new file mode 100644 index 00000000..9c4c14c9 --- /dev/null +++ b/qbraid_qir/qiskit/convert.py @@ -0,0 +1,102 @@ +# Copyright 2026 qBraid +# +# 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. + +# pylint: disable=line-too-long +# Portions of this module are adapted from microsoft/qiskit-qir +# (https://github.com/microsoft/qiskit-qir), with modifications by qBraid. +# The original MIT license notice is reproduced in NOTICE.md. +# pylint: enable=line-too-long + +""" +Module containing Qiskit to QIR conversion functions. + +""" + +from typing import Optional + +from pyqir import Context, Module, qir_module +from qiskit.circuit import QuantumCircuit +from qiskit.compiler import transpile as qiskit_transpile + +from .elements import QiskitModule, generate_module_id +from .exceptions import QiskitConversionError +from .maps import QISKIT_BASIS_GATES +from .visitor import BasicQiskitVisitor + + +def qiskit_to_qir( + circuit: QuantumCircuit, + name: Optional[str] = None, + transpile: bool = False, + **kwargs, +) -> Module: + """ + Converts a Qiskit QuantumCircuit to a PyQIR module. + + Args: + circuit: The Qiskit QuantumCircuit to convert. + name: Identifier for created QIR module. Auto-generated if not provided. + transpile: If True, transpile the circuit to the supported basis gate set + before conversion. This enables conversion of circuits containing + gates not directly supported in QIR. Defaults to False. + + Keyword Args: + initialize_runtime (bool): Whether to perform quantum runtime environment initialization, + default `True`. + record_output (bool): Whether to record output calls for registers, default `True`. + emit_barrier_calls (bool): Whether to emit barrier calls in the QIR, default `False`. + + Returns: + The QIR ``pyqir.Module`` representation of the input Qiskit circuit. + + Raises: + TypeError: If the input is not a valid Qiskit QuantumCircuit. + ValueError: If the input circuit is empty. + QiskitConversionError: If the conversion fails. + + Example: + >>> from qiskit import QuantumCircuit + >>> from qbraid_qir.qiskit import qiskit_to_qir + >>> + >>> qc = QuantumCircuit(2, 2) + >>> qc.h(0) + >>> qc.cx(0, 1) + >>> qc.measure([0, 1], [0, 1]) + >>> + >>> module = qiskit_to_qir(qc, name="bell") + >>> ir = str(module) + """ + if not isinstance(circuit, QuantumCircuit): + raise TypeError("Input quantum program must be of type qiskit.QuantumCircuit.") + + if len(circuit.data) == 0: + raise ValueError("Input quantum circuit must consist of at least one operation.") + + if transpile: + circuit = qiskit_transpile(circuit, basis_gates=QISKIT_BASIS_GATES) + + if name is None: + name = generate_module_id(circuit) + + llvm_module = qir_module(Context(), name) + module = QiskitModule.from_circuit(circuit, llvm_module) + + visitor = BasicQiskitVisitor(**kwargs) + module.accept(visitor) + + error = llvm_module.verify() + if error is not None: + raise QiskitConversionError(error) + + return llvm_module diff --git a/qbraid_qir/qiskit/elements.py b/qbraid_qir/qiskit/elements.py new file mode 100644 index 00000000..b495fe2e --- /dev/null +++ b/qbraid_qir/qiskit/elements.py @@ -0,0 +1,195 @@ +# Copyright 2026 qBraid +# +# 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. + +# pylint: disable=line-too-long +# Portions of this module are adapted from microsoft/qiskit-qir +# (https://github.com/microsoft/qiskit-qir), with modifications by qBraid. +# The original MIT license notice is reproduced in NOTICE.md. +# pylint: enable=line-too-long + +""" +Module defining Qiskit circuit elements for QIR conversion. + +""" + +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Optional, Union + +from pyqir import Module +from qiskit import ClassicalRegister, QuantumRegister +from qiskit.circuit import Clbit, Qubit +from qiskit.circuit.quantumcircuit import QuantumCircuit + +if TYPE_CHECKING: + from qiskit.circuit.instruction import Instruction + + +class _QuantumCircuitElement(metaclass=ABCMeta): + """Abstract base class for quantum circuit elements.""" + + @classmethod + def from_element_list(cls, elements): + """Create a list of circuit elements from a list of raw elements.""" + return [cls(elem) for elem in elements] + + @abstractmethod + def accept(self, visitor): + """Accept a visitor to process this element.""" + + +class _Register(_QuantumCircuitElement): + """Wrapper for a Qiskit register element.""" + + def __init__(self, register: Union[QuantumRegister, ClassicalRegister]): + self._register: Union[QuantumRegister, ClassicalRegister] = register + + def accept(self, visitor): + """Accept a visitor to process this register.""" + visitor.visit_register(self._register) + + +class _Instruction(_QuantumCircuitElement): + """Wrapper for a Qiskit instruction element.""" + + @classmethod + def from_element_list(cls, elements): + raise NotImplementedError( + "_Instruction requires (instruction, qargs, cargs); use direct construction." + ) + + def __init__( + self, + instruction: "Instruction", + qargs: tuple[Qubit, ...], + cargs: tuple[Clbit, ...], + ): + self._instruction: "Instruction" = instruction + self._qargs = qargs + self._cargs = cargs + + def accept(self, visitor): + """Accept a visitor to process this instruction.""" + visitor.visit_instruction(self._instruction, self._qargs, self._cargs) + + +def generate_module_id(circuit: QuantumCircuit) -> str: + """Generate a unique module ID for a circuit.""" + return circuit.name if circuit.name else "main" + + +class QiskitModule: + """Represents a Qiskit quantum circuit prepared for QIR conversion. + + Attributes: + circuit: The original Qiskit QuantumCircuit. + name: The name of the module. + module: The PyQIR Module being built. + num_qubits: Number of qubits in the circuit. + num_clbits: Number of classical bits in the circuit. + reg_sizes: List of sizes for each classical register. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + circuit: QuantumCircuit, + name: str, + module: Optional[Module], + num_qubits: int, + num_clbits: int, + reg_sizes: list[int], + elements: list[_QuantumCircuitElement], + ): + self._circuit = circuit + self._name = name + self._module = module + self._elements = elements + self._num_qubits = num_qubits + self._num_clbits = num_clbits + self.reg_sizes = reg_sizes + + @property + def circuit(self) -> QuantumCircuit: + """Return the underlying Qiskit circuit.""" + return self._circuit + + @property + def name(self) -> str: + """Return the module name.""" + return self._name + + @property + def module(self) -> Optional[Module]: + """Return the PyQIR module.""" + return self._module + + @property + def num_qubits(self) -> int: + """Return the number of qubits.""" + return self._num_qubits + + @property + def num_clbits(self) -> int: + """Return the number of classical bits.""" + return self._num_clbits + + @classmethod + def from_circuit( + cls, circuit: QuantumCircuit, module: Optional[Module] = None + ) -> "QiskitModule": + """Create a new QiskitModule from a Qiskit QuantumCircuit. + + Args: + circuit: The Qiskit QuantumCircuit to convert. + module: An optional existing PyQIR Module to use. + + Returns: + A new QiskitModule instance. + """ + elements: list[_QuantumCircuitElement] = [] + reg_sizes = [len(creg) for creg in circuit.cregs] + + # Add registers + elements.extend(_Register.from_element_list(circuit.qregs)) + elements.extend(_Register.from_element_list(circuit.cregs)) + + # Add instructions (updated for qiskit 2.x) + for circuit_instruction in circuit.data: + instruction = circuit_instruction.operation + qargs = circuit_instruction.qubits + cargs = circuit_instruction.clbits + elements.append(_Instruction(instruction, qargs, cargs)) + + name = generate_module_id(circuit) + + return cls( + circuit=circuit, + name=name, + module=module, + num_qubits=circuit.num_qubits, + num_clbits=circuit.num_clbits, + reg_sizes=reg_sizes, + elements=elements, + ) + + def accept(self, visitor): + """Accept a visitor to process this module. + + Args: + visitor: The visitor to accept. + """ + visitor.visit_qiskit_module(self) + for element in self._elements: + element.accept(visitor) + visitor.record_output() + visitor.finalize() diff --git a/qbraid_qir/qiskit/exceptions.py b/qbraid_qir/qiskit/exceptions.py new file mode 100644 index 00000000..80ce5587 --- /dev/null +++ b/qbraid_qir/qiskit/exceptions.py @@ -0,0 +1,24 @@ +# Copyright 2026 qBraid +# +# 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. + +""" +Module defining exceptions for Qiskit to QIR conversion. + +""" + +from qbraid_qir.exceptions import QirConversionError + + +class QiskitConversionError(QirConversionError): + """Exception raised when a Qiskit circuit cannot be converted to QIR.""" diff --git a/qbraid_qir/qiskit/maps.py b/qbraid_qir/qiskit/maps.py new file mode 100644 index 00000000..5a93fee6 --- /dev/null +++ b/qbraid_qir/qiskit/maps.py @@ -0,0 +1,112 @@ +# Copyright 2026 qBraid +# +# 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. + +""" +Module mapping supported Qiskit gates/operations to pyqir functions. + +""" + +from typing import Callable + +import pyqir._native + +from .exceptions import QiskitConversionError + + +def _identity(_builder, _qubit): + """Identity gate — no operation.""" + + +PYQIR_ONE_QUBIT_OP_MAP: dict[str, Callable] = { + "h": pyqir._native.h, + "x": pyqir._native.x, + "y": pyqir._native.y, + "z": pyqir._native.z, + "s": pyqir._native.s, + "sdg": pyqir._native.s_adj, + "t": pyqir._native.t, + "tdg": pyqir._native.t_adj, + "id": _identity, + "reset": pyqir._native.reset, +} + +PYQIR_ONE_QUBIT_ROTATION_MAP: dict[str, Callable] = { + "rx": pyqir._native.rx, + "ry": pyqir._native.ry, + "rz": pyqir._native.rz, +} + +PYQIR_TWO_QUBIT_OP_MAP: dict[str, Callable] = { + "cx": pyqir._native.cx, + "cz": pyqir._native.cz, + "swap": pyqir._native.swap, +} + +PYQIR_THREE_QUBIT_OP_MAP: dict[str, Callable] = { + "ccx": pyqir._native.ccx, +} + +PYQIR_MEASUREMENT_OP_MAP: dict[str, Callable] = { + "measure": pyqir._native.mz, + "m": pyqir._native.mz, + "mz": pyqir._native.mz, +} + +NOOP_INSTRUCTIONS: set[str] = {"delay"} + +SUPPORTED_INSTRUCTIONS: list[str] = sorted( + set(PYQIR_ONE_QUBIT_OP_MAP) + | set(PYQIR_ONE_QUBIT_ROTATION_MAP) + | set(PYQIR_TWO_QUBIT_OP_MAP) + | set(PYQIR_THREE_QUBIT_OP_MAP) + | set(PYQIR_MEASUREMENT_OP_MAP) + | {"barrier"} + | NOOP_INSTRUCTIONS +) + +QISKIT_BASIS_GATES: list[str] = sorted( + (set(PYQIR_ONE_QUBIT_OP_MAP) - {"reset"}) + | set(PYQIR_ONE_QUBIT_ROTATION_MAP) + | set(PYQIR_TWO_QUBIT_OP_MAP) + | set(PYQIR_THREE_QUBIT_OP_MAP) + | {"measure", "reset"} +) + + +def map_qiskit_op_to_pyqir_callable(op_name: str) -> tuple[Callable, int]: + """Map a Qiskit operation to a PyQIR callable and expected qubit count. + + Args: + op_name: The Qiskit operation name. + + Returns: + A tuple of (callable, num_qubits). + + Raises: + QiskitConversionError: If the operation is unsupported. + """ + op_name_lower = op_name.lower() + + op_mappings: list[tuple[dict, int]] = [ + (PYQIR_ONE_QUBIT_OP_MAP, 1), + (PYQIR_ONE_QUBIT_ROTATION_MAP, 1), + (PYQIR_TWO_QUBIT_OP_MAP, 2), + (PYQIR_THREE_QUBIT_OP_MAP, 3), + ] + + for mapping, num_qubits in op_mappings: + if op_name_lower in mapping: + return mapping[op_name_lower], num_qubits + + raise QiskitConversionError(f"Unsupported Qiskit operation: {op_name}") diff --git a/qbraid_qir/qiskit/visitor.py b/qbraid_qir/qiskit/visitor.py new file mode 100644 index 00000000..509759c8 --- /dev/null +++ b/qbraid_qir/qiskit/visitor.py @@ -0,0 +1,310 @@ +# Copyright 2026 qBraid +# +# 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. + +# pylint: disable=line-too-long +# Portions of this module are adapted from microsoft/qiskit-qir +# (https://github.com/microsoft/qiskit-qir), with modifications by qBraid. +# The original MIT license notice is reproduced in NOTICE.md. +# pylint: enable=line-too-long + +""" +Module defining QiskitVisitor for QIR conversion. + +""" + +import logging +from abc import ABCMeta, abstractmethod +from typing import Union + +import pyqir +from pyqir import ( + BasicBlock, + Builder, + Constant, + IntType, + PointerType, + entry_point, + qis, + rt, +) +from qiskit import ClassicalRegister, QuantumRegister +from qiskit.circuit import Clbit, Qubit +from qiskit.circuit.instruction import Instruction + +from qbraid_qir._pyqir_compat import pointer_id + +from .elements import QiskitModule +from .exceptions import QiskitConversionError +from .maps import ( + NOOP_INSTRUCTIONS, + PYQIR_MEASUREMENT_OP_MAP, + PYQIR_ONE_QUBIT_OP_MAP, + PYQIR_ONE_QUBIT_ROTATION_MAP, + PYQIR_THREE_QUBIT_OP_MAP, + PYQIR_TWO_QUBIT_OP_MAP, + SUPPORTED_INSTRUCTIONS, +) + +logger = logging.getLogger(__name__) + + +class QuantumCircuitElementVisitor(metaclass=ABCMeta): + """Abstract base class for quantum circuit element visitors.""" + + @abstractmethod + def visit_register(self, register: Union[QuantumRegister, ClassicalRegister]) -> None: + """Visit a register element.""" + + @abstractmethod + def visit_instruction( + self, + instruction: Instruction, + qargs: tuple[Qubit, ...], + cargs: tuple[Clbit, ...], + ) -> None: + """Visit an instruction element.""" + + +class BasicQiskitVisitor( # pylint: disable=too-many-instance-attributes + QuantumCircuitElementVisitor +): + """A visitor for basic Qiskit circuit elements. + + This class traverses and converts Qiskit circuit elements to QIR. + + Args: + initialize_runtime: If True, quantum runtime will be initialized. Defaults to True. + record_output: If True, output of the circuit will be recorded. Defaults to True. + emit_barrier_calls: If True, barrier instructions will be emitted. Defaults to False. + """ + + def __init__( + self, + initialize_runtime: bool = True, + record_output: bool = True, + emit_barrier_calls: bool = False, + ): + self._module: pyqir.Module = None # type: ignore[assignment] + self._qiskit_module: QiskitModule | None = None + self._builder: pyqir.Builder = None # type: ignore[assignment] + self._entry_point: str = "" + self._qubit_labels: dict[Qubit, int] = {} + self._clbit_labels: dict[Clbit, int] = {} + self._measured_qubits: dict[int, bool] = {} + self._initialize_runtime = initialize_runtime + self._record_output = record_output + self._emit_barrier_calls = emit_barrier_calls + + def visit_qiskit_module(self, module: QiskitModule) -> None: + """Visit a QiskitModule and initialize the QIR builder. + + Args: + module: The QiskitModule to visit. + """ + logger.debug( + "Visiting Qiskit module '%s' (%d qubits, %d clbits)", + module.name, + module.num_qubits, + module.num_clbits, + ) + self._qubit_labels.clear() + self._clbit_labels.clear() + self._measured_qubits.clear() + + if module.module is None: + raise ValueError("QiskitModule must have a PyQIR module set before visiting.") + self._module = module.module + self._qiskit_module = module + context = self._module.context + entry = entry_point(self._module, module.name, module.num_qubits, module.num_clbits) + + self._entry_point = entry.name + self._builder = Builder(context) + self._builder.insert_at_end(BasicBlock(context, "entry", entry)) + + if self._initialize_runtime: + i8p = PointerType(IntType(context, 8)) + nullptr = Constant.null(i8p) + rt.initialize(self._builder, nullptr) + + @property + def entry_point(self) -> str: + """Return the entry point name.""" + return self._entry_point + + def _check_initialized(self) -> None: + """Raise if the visitor has not been initialized via visit_qiskit_module.""" + if self._module is None or self._builder is None: + raise RuntimeError( + "Visitor has not been initialized. Call visit_qiskit_module() first." + ) + + def finalize(self) -> None: + """Finalize the QIR module by adding a return instruction.""" + self._check_initialized() + self._builder.ret(None) + + def record_output(self) -> None: + """Record output for classical registers using the visited module's register layout.""" + if not self._record_output: + return + self._check_initialized() + + if self._qiskit_module is None: + raise RuntimeError( + "No QiskitModule has been visited. Call visit_qiskit_module() first." + ) + + i8p = PointerType(IntType(self._module.context, 8)) + + # Qiskit inverts the ordering of results within each register + # but keeps the overall register ordering + logical_id_base = 0 + for size in self._qiskit_module.reg_sizes: + rt.array_record_output( + self._builder, + pyqir.const(IntType(self._module.context, 64), size), + Constant.null(i8p), + ) + for index in range(size - 1, -1, -1): + result_ref = pyqir.result(self._module.context, logical_id_base + index) + rt.result_record_output(self._builder, result_ref, Constant.null(i8p)) + logical_id_base += size + + def visit_register(self, register: Union[QuantumRegister, ClassicalRegister]) -> None: + """Visit a register and assign labels to its bits. + + Args: + register: The quantum or classical register to visit. + """ + logger.debug("Visiting register '%s'", register.name) + if isinstance(register, QuantumRegister): + self._qubit_labels.update( + {bit: n + len(self._qubit_labels) for n, bit in enumerate(register)} + ) + logger.debug("Added labels for qubits %s", list(register)) + elif isinstance(register, ClassicalRegister): + self._clbit_labels.update( + {bit: n + len(self._clbit_labels) for n, bit in enumerate(register)} + ) + else: + raise QiskitConversionError(f"Register of type {type(register)} not supported.") + + def _process_composite_instruction( + self, + instruction: Instruction, + qargs: tuple[Qubit, ...], + cargs: tuple[Clbit, ...], + ) -> None: + """Process a composite (decomposable) instruction. + + Args: + instruction: The composite instruction. + qargs: Qubit arguments. + cargs: Classical bit arguments. + """ + subcircuit = instruction.definition + logger.debug( + "Processing composite instruction %s with qubits %s", + instruction.name, + qargs, + ) + + if len(qargs) != subcircuit.num_qubits: + raise QiskitConversionError( + f"Composite instruction {instruction.name} called with wrong number of qubits; " + f"{subcircuit.num_qubits} expected, {len(qargs)} provided" + ) + if len(cargs) != subcircuit.num_clbits: + raise QiskitConversionError( + f"Composite instruction {instruction.name} called with wrong number of clbits; " + f"{subcircuit.num_clbits} expected, {len(cargs)} provided" + ) + + # Process sub-instructions with mapped bits + for circuit_instruction in subcircuit.data: + inst = circuit_instruction.operation + i_qargs = circuit_instruction.qubits + i_cargs = circuit_instruction.clbits + mapped_qbits = tuple(qargs[subcircuit.qubits.index(i)] for i in i_qargs) + mapped_clbits = tuple(cargs[subcircuit.clbits.index(i)] for i in i_cargs) + logger.debug( + "Processing sub-instruction %s with mapped qubits %s", + inst.name, + mapped_qbits, + ) + self.visit_instruction(inst, mapped_qbits, mapped_clbits) + + def visit_instruction( + self, + instruction: Instruction, + qargs: tuple[Qubit, ...], + cargs: tuple[Clbit, ...], + ) -> None: + """Visit and convert an instruction to QIR. + + Args: + instruction: The instruction to visit. + qargs: Qubit arguments. + cargs: Classical bit arguments. + """ + qlabels = [self._qubit_labels[bit] for bit in qargs] + clabels = [self._clbit_labels[bit] for bit in cargs] + qubits = [pyqir.qubit(self._module.context, n) for n in qlabels] + results = [pyqir.result(self._module.context, n) for n in clabels] + + labels_str = ", ".join([str(label) for label in qlabels + clabels]) + logger.debug("Visiting instruction '%s' (%s)", instruction.name, labels_str) + + op_name = instruction.name.lower() + + if op_name in NOOP_INSTRUCTIONS: + return + + if op_name in PYQIR_MEASUREMENT_OP_MAP: + for qubit, result in zip(qubits, results, strict=True): + qubit_id = pointer_id(qubit) + if qubit_id is not None: + self._measured_qubits[qubit_id] = True + qis.mz(self._builder, qubit, result) + elif op_name == "barrier": + if self._emit_barrier_calls: + qis.barrier(self._builder) + elif op_name in PYQIR_ONE_QUBIT_OP_MAP: + PYQIR_ONE_QUBIT_OP_MAP[op_name](self._builder, qubits[0]) + elif op_name in PYQIR_ONE_QUBIT_ROTATION_MAP: + PYQIR_ONE_QUBIT_ROTATION_MAP[op_name]( + self._builder, float(instruction.params[0]), qubits[0] + ) + elif op_name in PYQIR_TWO_QUBIT_OP_MAP: + PYQIR_TWO_QUBIT_OP_MAP[op_name](self._builder, qubits[0], qubits[1]) + elif op_name in PYQIR_THREE_QUBIT_OP_MAP: + PYQIR_THREE_QUBIT_OP_MAP[op_name](self._builder, *qubits) + elif instruction.definition is not None: + self._process_composite_instruction(instruction, qargs, cargs) + else: + raise QiskitConversionError( + f"Gate '{instruction.name}' is not supported. " + f"Please transpile using supported gates: {SUPPORTED_INSTRUCTIONS}" + ) + + def ir(self) -> str: + """Return the QIR as a string.""" + self._check_initialized() + return str(self._module) + + def bitcode(self) -> bytes: + """Return the QIR as bitcode.""" + self._check_initialized() + return self._module.bitcode diff --git a/tests/qiskit_qir/__init__.py b/tests/qiskit_qir/__init__.py new file mode 100644 index 00000000..3cbf1654 --- /dev/null +++ b/tests/qiskit_qir/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 qBraid +# +# 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. + +"""Qiskit QIR tests package.""" diff --git a/tests/qiskit_qir/conftest.py b/tests/qiskit_qir/conftest.py new file mode 100644 index 00000000..6deaccd9 --- /dev/null +++ b/tests/qiskit_qir/conftest.py @@ -0,0 +1,166 @@ +# Copyright 2026 qBraid +# +# 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. + +""" +Pytest fixtures for Qiskit QIR tests. + +""" + +import pytest +from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister + + +@pytest.fixture() +def bell_circuit(): + """A simple Bell state circuit.""" + circuit = QuantumCircuit(2, 2) + circuit.h(0) + circuit.cx(0, 1) + circuit.measure([0, 1], [0, 1]) + return circuit + + +@pytest.fixture() +def ghz_circuit(): + """A 3-qubit GHZ state circuit.""" + circuit = QuantumCircuit(3, 3) + circuit.h(0) + circuit.cx(0, 1) + circuit.cx(1, 2) + circuit.measure([0, 1, 2], [0, 1, 2]) + return circuit + + +@pytest.fixture() +def single_qubit_gates_circuit(): + """Circuit with various single-qubit gates.""" + circuit = QuantumCircuit(1) + circuit.h(0) + circuit.x(0) + circuit.y(0) + circuit.z(0) + circuit.s(0) + circuit.sdg(0) + circuit.t(0) + circuit.tdg(0) + return circuit + + +@pytest.fixture() +def rotation_gates_circuit(): + """Circuit with rotation gates.""" + circuit = QuantumCircuit(1) + circuit.rx(0.5, 0) + circuit.ry(1.0, 0) + circuit.rz(1.5, 0) + return circuit + + +@pytest.fixture() +def two_qubit_gates_circuit(): + """Circuit with two-qubit gates.""" + circuit = QuantumCircuit(2) + circuit.cx(0, 1) + circuit.cz(0, 1) + circuit.swap(0, 1) + return circuit + + +@pytest.fixture() +def three_qubit_gates_circuit(): + """Circuit with three-qubit gates.""" + circuit = QuantumCircuit(3) + circuit.ccx(0, 1, 2) + return circuit + + +@pytest.fixture() +def reset_circuit(): + """Circuit with reset gate.""" + circuit = QuantumCircuit(1) + circuit.reset(0) + circuit.h(0) + return circuit + + +@pytest.fixture() +def identity_circuit(): + """Circuit with identity gate.""" + circuit = QuantumCircuit(1) + circuit.id(0) + return circuit + + +@pytest.fixture() +def barrier_circuit(): + """Circuit with barrier.""" + circuit = QuantumCircuit(2) + circuit.h(0) + circuit.barrier() + circuit.cx(0, 1) + return circuit + + +@pytest.fixture() +def delay_circuit(): + """Circuit with delay instruction.""" + circuit = QuantumCircuit(1) + circuit.h(0) + circuit.delay(100, 0, "ns") + circuit.x(0) + return circuit + + +@pytest.fixture() +def named_registers_circuit(): + """Circuit with named quantum and classical registers.""" + qr = QuantumRegister(2, name="qreg") + cr = ClassicalRegister(2, name="creg") + circuit = QuantumCircuit(qr, cr, name="named_circuit") + circuit.h(qr[0]) + circuit.cx(qr[0], qr[1]) + circuit.measure(qr, cr) + return circuit + + +@pytest.fixture() +def multiple_registers_circuit(): + """Circuit with multiple registers.""" + qr1 = QuantumRegister(2, name="q1") + qr2 = QuantumRegister(1, name="q2") + cr1 = ClassicalRegister(2, name="c1") + cr2 = ClassicalRegister(1, name="c2") + circuit = QuantumCircuit(qr1, qr2, cr1, cr2, name="multi_reg") + circuit.h(qr1[0]) + circuit.cx(qr1[0], qr1[1]) + circuit.h(qr2[0]) + circuit.measure(qr1, cr1) + circuit.measure(qr2, cr2) + return circuit + + +@pytest.fixture() +def composite_gate_circuit(): + """Circuit with a composite (custom) gate.""" + # Create a custom gate from a circuit + sub_circuit = QuantumCircuit(2, name="bell_prep") + sub_circuit.h(0) + sub_circuit.cx(0, 1) + bell_gate = sub_circuit.to_gate() + + # Use the custom gate in a larger circuit + circuit = QuantumCircuit(2, 2) + circuit.append(bell_gate, [0, 1]) + circuit.measure([0, 1], [0, 1]) + return circuit diff --git a/tests/qiskit_qir/test_basic_gates.py b/tests/qiskit_qir/test_basic_gates.py new file mode 100644 index 00000000..cf47c7cc --- /dev/null +++ b/tests/qiskit_qir/test_basic_gates.py @@ -0,0 +1,526 @@ +# Copyright 2026 qBraid +# +# 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. + +""" +Unit tests for basic Qiskit gate conversions to QIR with sequential equivalence checks. + +""" + +import math + +import pytest +from pyqir import is_entry_point, required_num_qubits, required_num_results +from qiskit import QuantumCircuit + +from qbraid_qir._pyqir_compat import pyqir_uses_opaque_pointers +from qbraid_qir.qiskit import qiskit_to_qir + +_OPAQUE = pyqir_uses_opaque_pointers() + + +def _get_body(module): + """Extract the entry point body as a list of stripped instruction lines.""" + func = next(filter(is_entry_point, module.functions)) + lines = str(func).splitlines()[2:-1] + return [line.strip() for line in lines] + + +def _get_entry_point(module): + """Get the entry point function from a module.""" + return next(filter(is_entry_point, module.functions)) + + +def _qubit_ref(n): + """Return the QIR qubit reference string for qubit index *n*.""" + if _OPAQUE: + return "ptr null" if n == 0 else f"ptr inttoptr (i64 {n} to ptr)" + return "%Qubit* null" if n == 0 else f"%Qubit* inttoptr (i64 {n} to %Qubit*)" + + +def _result_ref(n): + """Return the QIR result reference string for result index *n*.""" + if _OPAQUE: + return "ptr null" if n == 0 else f"ptr inttoptr (i64 {n} to ptr)" + return "%Result* null" if n == 0 else f"%Result* inttoptr (i64 {n} to %Result*)" + + +def _op_call(name, qubit=0): + """Generate expected single-qubit gate call string.""" + return f"call void @__quantum__qis__{name}__body({_qubit_ref(qubit)})" + + +def _adj_call(name, qubit=0): + """Generate expected adjoint gate call string.""" + return f"call void @__quantum__qis__{name}__adj({_qubit_ref(qubit)})" + + +def _rot_call(name, angle, qubit=0): + """Generate expected rotation gate call string.""" + return f"call void @__quantum__qis__{name}__body(double {angle:#e}, {_qubit_ref(qubit)})" + + +def _two_qubit_call(name, qb1=0, qb2=1): + """Generate expected two-qubit gate call string.""" + return f"call void @__quantum__qis__{name}__body({_qubit_ref(qb1)}, {_qubit_ref(qb2)})" + + +def _three_qubit_call(name, qb1=0, qb2=1, qb3=2): + """Generate expected three-qubit gate call string.""" + qubits = ", ".join(_qubit_ref(q) for q in [qb1, qb2, qb3]) + return f"call void @__quantum__qis__{name}__body({qubits})" + + +def _mz_call(qubit=0, result=0): + """Generate expected measurement call string.""" + return f"call void @__quantum__qis__mz__body({_qubit_ref(qubit)}, {_result_ref(result)})" + + +def _assert_body_ops(module, expected_ops): + """Assert that the QIR body contains exactly the expected operations in order. + + Args: + module: PyQIR Module. + expected_ops: List of expected QIR instruction strings. + """ + body = _get_body(module) + + # Filter to only "call" lines and "ret" for comparison + gate_lines = [line for line in body if line.startswith("call void @__quantum__qis__")] + + expected_str = "\n ".join(expected_ops) + actual_str = "\n ".join(gate_lines) + assert len(gate_lines) == len(expected_ops), ( + f"Expected {len(expected_ops)} gate ops, got {len(gate_lines)}.\n" + f"Expected:\n {expected_str}\n" + f"Got:\n {actual_str}" + ) + for i, (actual, expected) in enumerate(zip(gate_lines, expected_ops)): + assert ( + actual == expected + ), f"Op mismatch at index {i}:\n expected: {expected}\n actual: {actual}" + + +# --------------------------------------------------------------------------- +# Single-qubit gates +# --------------------------------------------------------------------------- + + +class TestSingleQubitGates: + """Tests for single qubit gate conversions with sequential equivalence.""" + + # Gate name maps to itself for single-qubit gates + GATES = {"h", "x", "y", "z", "s", "t", "reset"} + + @pytest.mark.parametrize("gate_name", GATES) + def test_single_qubit_gate(self, gate_name): + circuit = QuantumCircuit(1) + getattr(circuit, gate_name)(0) + + module = qiskit_to_qir(circuit) + func = _get_entry_point(module) + assert required_num_qubits(func) == 1 + assert required_num_results(func) == 0 + + _assert_body_ops(module, [_op_call(gate_name, 0)]) + + @pytest.mark.parametrize("gate_name", GATES) + def test_single_qubit_gate_on_qubit_1(self, gate_name): + """Test gate applied to qubit index 1 (not 0).""" + circuit = QuantumCircuit(2) + getattr(circuit, gate_name)(1) + + module = qiskit_to_qir(circuit) + _assert_body_ops(module, [_op_call(gate_name, 1)]) + + +class TestAdjointGates: + """Tests for adjoint gate conversions with sequential equivalence.""" + + GATES = {"sdg": "s", "tdg": "t"} + + @pytest.mark.parametrize("gate_name,qir_name", GATES.items()) + def test_adjoint_gate(self, gate_name, qir_name): + circuit = QuantumCircuit(1) + getattr(circuit, gate_name)(0) + + module = qiskit_to_qir(circuit) + func = _get_entry_point(module) + assert required_num_qubits(func) == 1 + assert required_num_results(func) == 0 + + _assert_body_ops(module, [_adj_call(qir_name, 0)]) + + +class TestIdentityGate: + """Tests for identity gate (should be a no-op).""" + + def test_identity_is_noop(self): + circuit = QuantumCircuit(1) + circuit.id(0) + + module = qiskit_to_qir(circuit) + _assert_body_ops(module, []) + + def test_identity_between_gates(self): + """Identity between real gates should not affect the sequence.""" + circuit = QuantumCircuit(1) + circuit.h(0) + circuit.id(0) + circuit.x(0) + + module = qiskit_to_qir(circuit) + _assert_body_ops(module, [_op_call("h", 0), _op_call("x", 0)]) + + +# --------------------------------------------------------------------------- +# Rotation gates +# --------------------------------------------------------------------------- + + +class TestRotationGates: + """Tests for rotation gate conversions with sequential equivalence.""" + + @pytest.mark.parametrize("gate_name", ["rx", "ry", "rz"]) + def test_rotation_gate(self, gate_name): + circuit = QuantumCircuit(1) + getattr(circuit, gate_name)(0.5, 0) + + module = qiskit_to_qir(circuit) + func = _get_entry_point(module) + assert required_num_qubits(func) == 1 + assert required_num_results(func) == 0 + + _assert_body_ops(module, [_rot_call(gate_name, 0.5, 0)]) + + @pytest.mark.parametrize("angle", [0.0, math.pi, 2 * math.pi, -math.pi / 4]) + def test_rotation_edge_angles(self, angle): + """Test rotation gates with edge-case angles.""" + circuit = QuantumCircuit(1) + circuit.rx(angle, 0) + + module = qiskit_to_qir(circuit) + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + assert len(gate_ops) == 1 + assert "qis__rx__body" in gate_ops[0] + + def test_rotation_sequence(self): + """Test a sequence of different rotation gates on the same qubit.""" + circuit = QuantumCircuit(1) + circuit.rx(0.5, 0) + circuit.ry(1.0, 0) + circuit.rz(1.5, 0) + + module = qiskit_to_qir(circuit) + _assert_body_ops( + module, + [ + _rot_call("rx", 0.5, 0), + _rot_call("ry", 1.0, 0), + _rot_call("rz", 1.5, 0), + ], + ) + + +# --------------------------------------------------------------------------- +# Two-qubit gates +# --------------------------------------------------------------------------- + + +class TestTwoQubitGates: + """Tests for two qubit gate conversions with sequential equivalence.""" + + GATES = {"cx": "cnot", "cz": "cz", "swap": "swap"} + + @pytest.mark.parametrize("gate_name,qir_name", GATES.items()) + def test_two_qubit_gate(self, gate_name, qir_name): + circuit = QuantumCircuit(2) + getattr(circuit, gate_name)(0, 1) + + module = qiskit_to_qir(circuit) + func = _get_entry_point(module) + assert required_num_qubits(func) == 2 + assert required_num_results(func) == 0 + + _assert_body_ops(module, [_two_qubit_call(qir_name, 0, 1)]) + + @pytest.mark.parametrize("gate_name,qir_name", GATES.items()) + def test_two_qubit_gate_reversed(self, gate_name, qir_name): + """Test two-qubit gate with reversed qubit order.""" + circuit = QuantumCircuit(2) + getattr(circuit, gate_name)(1, 0) + + module = qiskit_to_qir(circuit) + _assert_body_ops(module, [_two_qubit_call(qir_name, 1, 0)]) + + def test_two_qubit_sequence(self): + """All two-qubit gates in sequence.""" + circuit = QuantumCircuit(2) + circuit.cx(0, 1) + circuit.cz(0, 1) + circuit.swap(0, 1) + + module = qiskit_to_qir(circuit) + _assert_body_ops( + module, + [ + _two_qubit_call("cnot", 0, 1), + _two_qubit_call("cz", 0, 1), + _two_qubit_call("swap", 0, 1), + ], + ) + + +# --------------------------------------------------------------------------- +# Three-qubit gates +# --------------------------------------------------------------------------- + + +class TestThreeQubitGates: + """Tests for three qubit gate conversions with sequential equivalence.""" + + def test_ccx_gate(self): + circuit = QuantumCircuit(3) + circuit.ccx(0, 1, 2) + + module = qiskit_to_qir(circuit) + func = _get_entry_point(module) + assert required_num_qubits(func) == 3 + assert required_num_results(func) == 0 + + _assert_body_ops(module, [_three_qubit_call("ccx", 0, 1, 2)]) + + def test_ccx_different_qubits(self): + """CCX with non-sequential qubit ordering.""" + circuit = QuantumCircuit(4) + circuit.ccx(3, 1, 0) + + module = qiskit_to_qir(circuit) + _assert_body_ops(module, [_three_qubit_call("ccx", 3, 1, 0)]) + + +# --------------------------------------------------------------------------- +# Measurement +# --------------------------------------------------------------------------- + + +class TestMeasurement: + """Tests for measurement operations with sequential equivalence.""" + + def test_single_measurement(self): + circuit = QuantumCircuit(1, 1) + circuit.measure(0, 0) + + module = qiskit_to_qir(circuit) + func = _get_entry_point(module) + assert required_num_qubits(func) == 1 + assert required_num_results(func) == 1 + + _assert_body_ops(module, [_mz_call(0, 0)]) + + def test_multiple_measurements(self): + circuit = QuantumCircuit(3, 3) + circuit.measure([0, 1, 2], [0, 1, 2]) + + module = qiskit_to_qir(circuit) + func = _get_entry_point(module) + assert required_num_qubits(func) == 3 + assert required_num_results(func) == 3 + + _assert_body_ops( + module, + [ + _mz_call(0, 0), + _mz_call(1, 1), + _mz_call(2, 2), + ], + ) + + def test_gates_then_measurement(self): + """H gate then measurement — verify ordering.""" + circuit = QuantumCircuit(1, 1) + circuit.h(0) + circuit.measure(0, 0) + + module = qiskit_to_qir(circuit) + _assert_body_ops(module, [_op_call("h", 0), _mz_call(0, 0)]) + + +# --------------------------------------------------------------------------- +# Output recording +# --------------------------------------------------------------------------- + + +class TestOutputRecording: + """Tests for output recording functionality.""" + + def test_output_recording_single_register(self): + circuit = QuantumCircuit(2, 2) + circuit.h(0) + circuit.cx(0, 1) + circuit.measure([0, 1], [0, 1]) + + module = qiskit_to_qir(circuit) + body = _get_body(module) + + rt_calls = [line for line in body if "__quantum__rt__" in line] + # init + array_record_output + 2 result_record_output + assert any("array_record_output" in line for line in rt_calls) + assert sum("result_record_output" in line for line in rt_calls) == 2 + + def test_output_recording_disabled(self): + circuit = QuantumCircuit(2, 2) + circuit.h(0) + circuit.cx(0, 1) + circuit.measure([0, 1], [0, 1]) + + module = qiskit_to_qir(circuit, record_output=False) + ir = str(module) + assert "__quantum__rt__array_record_output" not in ir + assert "__quantum__rt__result_record_output" not in ir + + +# --------------------------------------------------------------------------- +# Barrier and delay +# --------------------------------------------------------------------------- + + +class TestBarrierAndDelay: + """Tests for barrier and delay instruction handling.""" + + def test_barrier_not_emitted_by_default(self): + circuit = QuantumCircuit(2) + circuit.h(0) + circuit.barrier() + circuit.cx(0, 1) + + module = qiskit_to_qir(circuit) + _assert_body_ops( + module, + [ + _op_call("h", 0), + _two_qubit_call("cnot", 0, 1), + ], + ) + + def test_barrier_emitted_when_enabled(self): + circuit = QuantumCircuit(2) + circuit.h(0) + circuit.barrier() + circuit.cx(0, 1) + + module = qiskit_to_qir(circuit, emit_barrier_calls=True) + body = _get_body(module) + gate_and_barrier = [line for line in body if line.startswith("call void @__quantum__qis__")] + + assert len(gate_and_barrier) == 3 + assert "barrier" in gate_and_barrier[1] + + def test_delay_ignored(self): + circuit = QuantumCircuit(1) + circuit.h(0) + circuit.delay(100, 0, "ns") + circuit.x(0) + + module = qiskit_to_qir(circuit) + _assert_body_ops(module, [_op_call("h", 0), _op_call("x", 0)]) + + +# --------------------------------------------------------------------------- +# Multi-gate sequences (sequential equivalence) +# --------------------------------------------------------------------------- + + +class TestMultiGateSequences: + """Tests that verify exact gate ordering in multi-gate circuits.""" + + def test_bell_state_preparation(self): + """H then CX — canonical Bell state prep.""" + circuit = QuantumCircuit(2) + circuit.h(0) + circuit.cx(0, 1) + + module = qiskit_to_qir(circuit) + _assert_body_ops( + module, + [ + _op_call("h", 0), + _two_qubit_call("cnot", 0, 1), + ], + ) + + def test_all_single_qubit_gates_sequence(self): + """All single-qubit gates in a defined order.""" + circuit = QuantumCircuit(1) + circuit.h(0) + circuit.x(0) + circuit.y(0) + circuit.z(0) + circuit.s(0) + circuit.sdg(0) + circuit.t(0) + circuit.tdg(0) + + module = qiskit_to_qir(circuit) + _assert_body_ops( + module, + [ + _op_call("h", 0), + _op_call("x", 0), + _op_call("y", 0), + _op_call("z", 0), + _op_call("s", 0), + _adj_call("s", 0), + _op_call("t", 0), + _adj_call("t", 0), + ], + ) + + def test_mixed_single_and_two_qubit(self): + """Interleaved single and two-qubit gates.""" + circuit = QuantumCircuit(3) + circuit.h(0) + circuit.cx(0, 1) + circuit.h(2) + circuit.cz(1, 2) + + module = qiskit_to_qir(circuit) + _assert_body_ops( + module, + [ + _op_call("h", 0), + _two_qubit_call("cnot", 0, 1), + _op_call("h", 2), + _two_qubit_call("cz", 1, 2), + ], + ) + + def test_gates_interleaved_with_measurements(self): + """Gates and measurements interleaved.""" + circuit = QuantumCircuit(2, 2) + circuit.h(0) + circuit.measure(0, 0) + circuit.h(1) + circuit.measure(1, 1) + + module = qiskit_to_qir(circuit) + _assert_body_ops( + module, + [ + _op_call("h", 0), + _mz_call(0, 0), + _op_call("h", 1), + _mz_call(1, 1), + ], + ) diff --git a/tests/qiskit_qir/test_complex_circuits.py b/tests/qiskit_qir/test_complex_circuits.py new file mode 100644 index 00000000..4619db32 --- /dev/null +++ b/tests/qiskit_qir/test_complex_circuits.py @@ -0,0 +1,466 @@ +# Copyright 2026 qBraid +# +# 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. + +""" +Tests for complex Qiskit circuits, transpilation support, and edge cases. + +""" + +import math + +import pytest +from pyqir import is_entry_point, required_num_qubits, required_num_results +from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister +from qiskit.circuit import Gate, Parameter +from qiskit.circuit.random import random_circuit + +from qbraid_qir.qiskit import qiskit_to_qir +from qbraid_qir.qiskit.exceptions import QiskitConversionError + + +def _get_body(module): + func = next(filter(is_entry_point, module.functions)) + lines = str(func).splitlines()[2:-1] + return [line.strip() for line in lines] + + +def _gate_ops(module): + """Return only qis__ gate call lines from the body.""" + return [line for line in _get_body(module) if "qis__" in line] + + +# --------------------------------------------------------------------------- +# Complex circuit tests +# --------------------------------------------------------------------------- + + +class TestQuantumTeleportation: + """Test quantum teleportation circuit.""" + + def test_teleportation_circuit(self): + qr = QuantumRegister(3, "q") + cr = ClassicalRegister(2, "c") + circuit = QuantumCircuit(qr, cr) + + # Create Bell pair between q1 and q2 + circuit.h(1) + circuit.cx(1, 2) + + # Alice's operations + circuit.cx(0, 1) + circuit.h(0) + + # Measure + circuit.measure(0, 0) + circuit.measure(1, 1) + + module = qiskit_to_qir(circuit) + func = next(filter(is_entry_point, module.functions)) + assert required_num_qubits(func) == 3 + assert required_num_results(func) == 2 + + ops = _gate_ops(module) + gate_names = [] + for op in ops: + if "qis__h__body" in op: + gate_names.append("h") + elif "qis__cnot__body" in op: + gate_names.append("cnot") + elif "qis__mz__body" in op: + gate_names.append("mz") + + # H, CX, CX, H, MZ, MZ + assert gate_names == ["h", "cnot", "cnot", "h", "mz", "mz"] + + +class TestGroverTwoQubit: + """Test 2-qubit Grover's algorithm circuit.""" + + def test_grover_circuit(self): + circuit = QuantumCircuit(2, 2) + + # Initialize superposition + circuit.h(0) + circuit.h(1) + + # Oracle (mark |11>) + circuit.cz(0, 1) + + # Diffusion operator + circuit.h(0) + circuit.h(1) + circuit.x(0) + circuit.x(1) + circuit.cz(0, 1) + circuit.x(0) + circuit.x(1) + circuit.h(0) + circuit.h(1) + + circuit.measure([0, 1], [0, 1]) + + module = qiskit_to_qir(circuit) + ops = _gate_ops(module) + assert len(ops) == 14 # 2H + CZ + 2H + 2X + CZ + 2X + 2H + 2MZ + + +class TestParameterizedCircuits: + """Test parameterized circuits with bound parameters.""" + + def test_bound_parameter(self): + theta = Parameter("theta") + circuit = QuantumCircuit(1) + circuit.rx(theta, 0) + bound = circuit.assign_parameters({theta: math.pi / 4}) + + module = qiskit_to_qir(bound) + ops = _gate_ops(module) + assert len(ops) == 1 + assert "qis__rx__body" in ops[0] + # pi/4 ≈ 0.7854; pyqir may emit as hex (0x3FE921FB…) or decimal + assert ("0x3FE921FB" in ops[0]) or ("7.853982e-01" in ops[0]) or ("0.785398" in ops[0]) + + def test_multiple_bound_parameters(self): + a = Parameter("a") + b = Parameter("b") + circuit = QuantumCircuit(1) + circuit.rx(a, 0) + circuit.ry(b, 0) + bound = circuit.assign_parameters({a: 0.5, b: 1.0}) + + module = qiskit_to_qir(bound) + ops = _gate_ops(module) + assert len(ops) == 2 + assert "qis__rx__body" in ops[0] + assert "qis__ry__body" in ops[1] + + +class TestCustomGates: + """Test custom (composite) gate decomposition.""" + + def test_simple_custom_gate(self): + """Custom gate that decomposes to H + CX.""" + sub = QuantumCircuit(2, name="my_bell") + sub.h(0) + sub.cx(0, 1) + bell_gate = sub.to_gate() + + circuit = QuantumCircuit(2) + circuit.append(bell_gate, [0, 1]) + + module = qiskit_to_qir(circuit) + ops = _gate_ops(module) + assert len(ops) == 2 + assert "qis__h__body" in ops[0] + assert "qis__cnot__body" in ops[1] + + def test_nested_custom_gate(self): + """Custom gate containing another custom gate.""" + inner = QuantumCircuit(1, name="inner_gate") + inner.h(0) + inner.z(0) + inner_gate = inner.to_gate() + + outer = QuantumCircuit(2, name="outer_gate") + outer.append(inner_gate, [0]) + outer.cx(0, 1) + outer_gate = outer.to_gate() + + circuit = QuantumCircuit(2) + circuit.append(outer_gate, [0, 1]) + + module = qiskit_to_qir(circuit) + ops = _gate_ops(module) + assert len(ops) == 3 + assert "qis__h__body" in ops[0] + assert "qis__z__body" in ops[1] + assert "qis__cnot__body" in ops[2] + + +class TestLargeCircuits: + """Test with larger circuits for regression.""" + + def test_10_qubit_circuit(self): + n = 10 + circuit = QuantumCircuit(n) + for i in range(n): + circuit.h(i) + for i in range(n - 1): + circuit.cx(i, i + 1) + + module = qiskit_to_qir(circuit) + func = next(filter(is_entry_point, module.functions)) + assert required_num_qubits(func) == n + + ops = _gate_ops(module) + h_count = sum(1 for line in ops if "qis__h__body" in line) + cx_count = sum(1 for line in ops if "qis__cnot__body" in line) + assert h_count == n + assert cx_count == n - 1 + + def test_20_qubit_ghz(self): + n = 20 + circuit = QuantumCircuit(n, n) + circuit.h(0) + for i in range(n - 1): + circuit.cx(i, i + 1) + circuit.measure(range(n), range(n)) + + module = qiskit_to_qir(circuit) + func = next(filter(is_entry_point, module.functions)) + assert required_num_qubits(func) == n + assert required_num_results(func) == n + + +class TestMultipleClassicalRegisters: + """Test circuits with multiple classical registers.""" + + def test_selective_measurement(self): + """Measure only some qubits into specific registers.""" + qr = QuantumRegister(3, "q") + cr1 = ClassicalRegister(1, "c1") + cr2 = ClassicalRegister(2, "c2") + circuit = QuantumCircuit(qr, cr1, cr2) + circuit.h(0) + circuit.h(1) + circuit.h(2) + circuit.measure(0, cr1[0]) + circuit.measure(1, cr2[0]) + circuit.measure(2, cr2[1]) + + module = qiskit_to_qir(circuit) + func = next(filter(is_entry_point, module.functions)) + assert required_num_qubits(func) == 3 + assert required_num_results(func) == 3 + + body = _get_body(module) + array_records = [line for line in body if "array_record_output" in line] + assert len(array_records) == 2 # Two classical registers + + def test_three_classical_registers(self): + qr = QuantumRegister(3) + cr1 = ClassicalRegister(1, "a") + cr2 = ClassicalRegister(1, "b") + cr3 = ClassicalRegister(1, "c") + circuit = QuantumCircuit(qr, cr1, cr2, cr3) + circuit.h(0) + circuit.h(1) + circuit.h(2) + circuit.measure(0, cr1[0]) + circuit.measure(1, cr2[0]) + circuit.measure(2, cr3[0]) + + module = qiskit_to_qir(circuit) + body = _get_body(module) + array_records = [line for line in body if "array_record_output" in line] + assert len(array_records) == 3 + + +# --------------------------------------------------------------------------- +# Transpilation tests +# --------------------------------------------------------------------------- + + +class TestTranspilation: + """Test the transpile=True flag for converting unsupported gates.""" + + def test_ecr_gate_with_transpile(self): + """ECR gate is not natively supported but can be transpiled.""" + circuit = QuantumCircuit(2) + circuit.ecr(0, 1) + + module = qiskit_to_qir(circuit, transpile=True) + ops = _gate_ops(module) + assert len(ops) > 0 + # Should not contain ECR in the output + assert not any("ecr" in line.lower() for line in ops) + + def test_u_gate_with_transpile(self): + """U gate transpiled to basis gates.""" + circuit = QuantumCircuit(1) + circuit.u(math.pi / 4, math.pi / 2, math.pi, 0) + + module = qiskit_to_qir(circuit, transpile=True) + ops = _gate_ops(module) + assert len(ops) > 0 + + def test_cswap_with_transpile(self): + """CSWAP (Fredkin) gate transpiled to basis gates.""" + circuit = QuantumCircuit(3) + circuit.cswap(0, 1, 2) + + module = qiskit_to_qir(circuit, transpile=True) + ops = _gate_ops(module) + assert len(ops) > 0 + + def test_transpile_preserves_simple_circuit(self): + """Transpiling a circuit that's already in basis gates shouldn't break it.""" + circuit = QuantumCircuit(2, 2) + circuit.h(0) + circuit.cx(0, 1) + circuit.measure([0, 1], [0, 1]) + + module_no_transpile = qiskit_to_qir(circuit, transpile=False) + module_transpile = qiskit_to_qir(circuit, transpile=True) + + ops_no = _gate_ops(module_no_transpile) + ops_yes = _gate_ops(module_transpile) + + # Both should have the same gate types + no_names = [line.split("qis__")[1].split("__")[0] for line in ops_no] + yes_names = [line.split("qis__")[1].split("__")[0] for line in ops_yes] + assert sorted(no_names) == sorted(yes_names) + + def test_ecr_without_transpile_uses_decomposition(self): + """ECR without transpile should still work via composite decomposition.""" + circuit = QuantumCircuit(2) + circuit.ecr(0, 1) + + # Should work because ECR has a definition (decomposition) + module = qiskit_to_qir(circuit, transpile=False) + ops = _gate_ops(module) + assert len(ops) > 0 + + def test_unsupported_gate_no_definition_without_transpile(self): + """Gate with no decomposition and transpile=False should raise.""" + + class BadGate(Gate): + def __init__(self): + super().__init__("bad_gate", 1, []) + + circuit = QuantumCircuit(1) + circuit.append(BadGate(), [0]) + + with pytest.raises(QiskitConversionError, match="not supported"): + qiskit_to_qir(circuit, transpile=False) + + def test_circuit_with_barrier_and_transpile(self): + """Barriers should be preserved through transpilation.""" + circuit = QuantumCircuit(2) + circuit.h(0) + circuit.barrier() + circuit.ecr(0, 1) + + module = qiskit_to_qir(circuit, transpile=True, emit_barrier_calls=True) + body = _get_body(module) + assert any("barrier" in line for line in body) + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + """Edge case tests.""" + + def test_single_gate_circuit(self): + """Minimal circuit with exactly one gate.""" + circuit = QuantumCircuit(1) + circuit.h(0) + module = qiskit_to_qir(circuit) + ops = _gate_ops(module) + assert len(ops) == 1 + + def test_measurement_only_circuit(self): + """Circuit with only measurements.""" + circuit = QuantumCircuit(1, 1) + circuit.measure(0, 0) + module = qiskit_to_qir(circuit) + ops = _gate_ops(module) + assert len(ops) == 1 + assert "mz" in ops[0] + + def test_reset_then_gate(self): + """Reset followed by a gate.""" + circuit = QuantumCircuit(1) + circuit.reset(0) + circuit.h(0) + module = qiskit_to_qir(circuit) + ops = _gate_ops(module) + assert len(ops) == 2 + assert "reset" in ops[0] + assert "h" in ops[1] + + def test_many_identity_gates(self): + """Multiple identity gates should all be no-ops.""" + circuit = QuantumCircuit(1) + circuit.id(0) + circuit.id(0) + circuit.id(0) + circuit.h(0) + module = qiskit_to_qir(circuit) + ops = _gate_ops(module) + assert len(ops) == 1 + assert "h" in ops[0] + + def test_all_runtime_options_disabled(self): + """No init, no output recording, no barrier emission.""" + circuit = QuantumCircuit(2, 2) + circuit.h(0) + circuit.barrier() + circuit.cx(0, 1) + circuit.measure([0, 1], [0, 1]) + + module = qiskit_to_qir( + circuit, initialize_runtime=False, record_output=False, emit_barrier_calls=False + ) + body = _get_body(module) + + assert not any("rt__initialize" in line for line in body) + assert not any("array_record_output" in line for line in body) + assert not any("result_record_output" in line for line in body) + assert not any("barrier" in line for line in body) + + def test_circuit_with_no_classical_bits(self): + """Circuit without classical bits should have no measurement output.""" + circuit = QuantumCircuit(2) + circuit.h(0) + circuit.cx(0, 1) + module = qiskit_to_qir(circuit) + func = next(filter(is_entry_point, module.functions)) + assert required_num_results(func) == 0 + + +# --------------------------------------------------------------------------- +# Random circuit tests +# --------------------------------------------------------------------------- + + +class TestRandomCircuits: + """Test conversion of randomly generated Qiskit circuits.""" + + @pytest.mark.parametrize("seed", range(10)) + def test_random_circuit_with_transpile(self, seed): + """Generate a random circuit and convert to QIR using transpile=True. + + Random circuits may contain gates outside the supported set, + so transpilation is used to decompose them first. + """ + num_qubits = (seed % 4) + 2 # 2..5 qubits + depth = (seed % 3) + 2 # 2..4 depth + + circuit = random_circuit(num_qubits, depth, seed=seed, measure=True) + + module = qiskit_to_qir(circuit, transpile=True) + + func = next(filter(is_entry_point, module.functions)) + assert required_num_qubits(func) == num_qubits + assert required_num_results(func) == num_qubits + + ops = _gate_ops(module) + assert len(ops) > 0 + + ir = str(module) + assert len(ir) > 0 diff --git a/tests/qiskit_qir/test_qiskit_to_qir.py b/tests/qiskit_qir/test_qiskit_to_qir.py new file mode 100644 index 00000000..e6eb98c7 --- /dev/null +++ b/tests/qiskit_qir/test_qiskit_to_qir.py @@ -0,0 +1,363 @@ +# Copyright 2026 qBraid +# +# 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. + +""" +Unit tests for Qiskit to QIR conversion — integration, error handling, API contract. + +""" + +import pyqir +import pytest +from pyqir import ( + Context, + is_entry_point, + qir_module, + required_num_qubits, + required_num_results, +) +from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister +from qiskit.circuit import Gate + +from qbraid_qir.qiskit import BasicQiskitVisitor, QiskitModule, qiskit_to_qir +from qbraid_qir.qiskit.exceptions import QiskitConversionError +from qbraid_qir.qiskit.maps import map_qiskit_op_to_pyqir_callable + + +def _get_body(module): + func = next(filter(is_entry_point, module.functions)) + lines = str(func).splitlines()[2:-1] + return [line.strip() for line in lines] + + +def _get_entry_point(module): + return next(filter(is_entry_point, module.functions)) + + +# --------------------------------------------------------------------------- +# Integration tests using fixtures +# --------------------------------------------------------------------------- + + +class TestQiskitToQir: + """Tests for the qiskit_to_qir function with sequential verification.""" + + def test_bell_circuit(self, bell_circuit): + module = qiskit_to_qir(bell_circuit) + func = _get_entry_point(module) + assert required_num_qubits(func) == 2 + assert required_num_results(func) == 2 + + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + assert "qis__h__body" in gate_ops[0] + assert "qis__cnot__body" in gate_ops[1] + assert "qis__mz__body" in gate_ops[2] + assert "qis__mz__body" in gate_ops[3] + + def test_ghz_circuit(self, ghz_circuit): + module = qiskit_to_qir(ghz_circuit) + func = _get_entry_point(module) + assert required_num_qubits(func) == 3 + assert required_num_results(func) == 3 + + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + # H, CX, CX, MZ, MZ, MZ + assert len(gate_ops) == 6 + assert "qis__h__body" in gate_ops[0] + assert "qis__cnot__body" in gate_ops[1] + assert "qis__cnot__body" in gate_ops[2] + + def test_single_qubit_gates(self, single_qubit_gates_circuit): + module = qiskit_to_qir(single_qubit_gates_circuit) + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + + expected_gates = ["h", "x", "y", "z", "s", "s__adj", "t", "t__adj"] + assert len(gate_ops) == len(expected_gates) + for op, gate in zip(gate_ops, expected_gates): + assert f"qis__{gate}" in op + + def test_rotation_gates(self, rotation_gates_circuit): + module = qiskit_to_qir(rotation_gates_circuit) + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + assert len(gate_ops) == 3 + assert "qis__rx__body" in gate_ops[0] + assert "qis__ry__body" in gate_ops[1] + assert "qis__rz__body" in gate_ops[2] + + def test_two_qubit_gates(self, two_qubit_gates_circuit): + module = qiskit_to_qir(two_qubit_gates_circuit) + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + assert len(gate_ops) == 3 + assert "qis__cnot__body" in gate_ops[0] + assert "qis__cz__body" in gate_ops[1] + assert "qis__swap__body" in gate_ops[2] + + def test_three_qubit_gates(self, three_qubit_gates_circuit): + module = qiskit_to_qir(three_qubit_gates_circuit) + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + assert len(gate_ops) == 1 + assert "qis__ccx__body" in gate_ops[0] + + def test_reset_gate(self, reset_circuit): + module = qiskit_to_qir(reset_circuit) + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + assert len(gate_ops) == 2 + assert "qis__reset__body" in gate_ops[0] + assert "qis__h__body" in gate_ops[1] + + def test_identity_gate(self, identity_circuit): + """Identity gate is now a true no-op.""" + module = qiskit_to_qir(identity_circuit) + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + assert len(gate_ops) == 0 + + def test_barrier_not_emitted_by_default(self, barrier_circuit): + module = qiskit_to_qir(barrier_circuit) + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + assert not any("barrier" in line for line in gate_ops) + + def test_barrier_emitted_when_enabled(self, barrier_circuit): + module = qiskit_to_qir(barrier_circuit, emit_barrier_calls=True) + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + assert any("barrier" in line for line in gate_ops) + + def test_delay_ignored(self, delay_circuit): + module = qiskit_to_qir(delay_circuit) + ir = str(module) + assert "delay" not in ir.lower() + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + assert len(gate_ops) == 2 + assert "qis__h__body" in gate_ops[0] + assert "qis__x__body" in gate_ops[1] + + def test_named_registers(self, named_registers_circuit): + module = qiskit_to_qir(named_registers_circuit, name="test_named") + ir = str(module) + assert "test_named" in ir + + def test_multiple_registers(self, multiple_registers_circuit): + module = qiskit_to_qir(multiple_registers_circuit) + func = _get_entry_point(module) + assert required_num_qubits(func) == 3 + assert required_num_results(func) == 3 + + body = _get_body(module) + # Check output recording has two array_record_output calls (2 classical registers) + array_records = [line for line in body if "array_record_output" in line] + assert len(array_records) == 2 + + def test_composite_gate(self, composite_gate_circuit): + """Composite gate should be decomposed into primitive gates.""" + module = qiskit_to_qir(composite_gate_circuit) + body = _get_body(module) + gate_ops = [line for line in body if "qis__" in line] + + # bell_prep decomposes to H + CX, then measure + gate_names = [] + for op in gate_ops: + if "qis__h__body" in op: + gate_names.append("h") + elif "qis__cnot__body" in op: + gate_names.append("cnot") + elif "qis__mz__body" in op: + gate_names.append("mz") + + assert "h" in gate_names + assert "cnot" in gate_names + assert "mz" in gate_names + + def test_custom_name(self, bell_circuit): + module = qiskit_to_qir(bell_circuit, name="custom_bell") + ir = str(module) + assert "custom_bell" in ir + + def test_no_runtime_init(self, bell_circuit): + module = qiskit_to_qir(bell_circuit, initialize_runtime=False) + ir = str(module) + assert "__quantum__rt__initialize" not in ir + + def test_no_output_recording(self, bell_circuit): + module = qiskit_to_qir(bell_circuit, record_output=False) + ir = str(module) + assert "__quantum__rt__result_record_output" not in ir + assert "__quantum__rt__array_record_output" not in ir + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestQiskitToQirErrors: + """Tests for error handling in qiskit_to_qir.""" + + def test_invalid_input_type(self): + with pytest.raises(TypeError, match="must be of type qiskit.QuantumCircuit"): + qiskit_to_qir("not a circuit") + + def test_empty_circuit(self): + circuit = QuantumCircuit(1) + with pytest.raises(ValueError, match="at least one operation"): + qiskit_to_qir(circuit) + + def test_unsupported_gate_no_definition(self): + """Gate with no decomposition should raise QiskitConversionError.""" + + class NoDefGate(Gate): + def __init__(self): + super().__init__("no_def_gate", 1, []) + + circuit = QuantumCircuit(1) + circuit.append(NoDefGate(), [0]) + with pytest.raises(QiskitConversionError, match="not supported"): + qiskit_to_qir(circuit) + + +# --------------------------------------------------------------------------- +# QiskitModule +# --------------------------------------------------------------------------- + + +class TestQiskitModule: + """Tests for QiskitModule class.""" + + def test_from_circuit(self, bell_circuit): + module = QiskitModule.from_circuit(bell_circuit, None) + assert module.num_qubits == 2 + assert module.num_clbits == 2 + assert module.name in (bell_circuit.name, "main") + + def test_circuit_property(self, bell_circuit): + module = QiskitModule.from_circuit(bell_circuit, None) + assert module.circuit is bell_circuit + + def test_reg_sizes(self): + """Test reg_sizes for multiple classical registers.""" + qr = QuantumRegister(3) + cr1 = ClassicalRegister(2) + cr2 = ClassicalRegister(1) + circuit = QuantumCircuit(qr, cr1, cr2) + circuit.h(0) + + module = QiskitModule.from_circuit(circuit, None) + assert module.reg_sizes == [2, 1] + + +# --------------------------------------------------------------------------- +# BasicQiskitVisitor +# --------------------------------------------------------------------------- + + +class TestBasicQiskitVisitor: + """Tests for BasicQiskitVisitor class.""" + + def test_visitor_entry_point(self, bell_circuit): + llvm_module = qir_module(Context(), "test") + qiskit_module = QiskitModule.from_circuit(bell_circuit, llvm_module) + + visitor = BasicQiskitVisitor() + qiskit_module.accept(visitor) + + assert visitor.entry_point is not None + assert len(visitor.entry_point) > 0 + + def test_visitor_ir_output(self, bell_circuit): + llvm_module = qir_module(Context(), "test") + qiskit_module = QiskitModule.from_circuit(bell_circuit, llvm_module) + + visitor = BasicQiskitVisitor() + qiskit_module.accept(visitor) + + ir = visitor.ir() + assert len(ir) > 0 + assert "__quantum__qis__h__body" in ir + + def test_visitor_bitcode_output(self, bell_circuit): + llvm_module = qir_module(Context(), "test") + qiskit_module = QiskitModule.from_circuit(bell_circuit, llvm_module) + + visitor = BasicQiskitVisitor() + qiskit_module.accept(visitor) + + bitcode = visitor.bitcode() + assert isinstance(bitcode, bytes) + assert len(bitcode) > 0 + + +# --------------------------------------------------------------------------- +# SDK API contract +# --------------------------------------------------------------------------- + + +class TestMaps: + """Tests for the maps module utility function.""" + + def test_map_one_qubit_op(self): + func, num_qubits = map_qiskit_op_to_pyqir_callable("h") + assert num_qubits == 1 + assert func is not None + + def test_map_rotation_op(self): + func, num_qubits = map_qiskit_op_to_pyqir_callable("rx") + assert num_qubits == 1 + assert func is not None + + def test_map_two_qubit_op(self): + func, num_qubits = map_qiskit_op_to_pyqir_callable("cx") + assert num_qubits == 2 + assert func is not None + + def test_map_three_qubit_op(self): + func, num_qubits = map_qiskit_op_to_pyqir_callable("ccx") + assert num_qubits == 3 + assert func is not None + + def test_map_unsupported_op(self): + with pytest.raises(QiskitConversionError, match="Unsupported"): + map_qiskit_op_to_pyqir_callable("nonexistent_gate") + + def test_map_case_insensitive(self): + fn1, n1 = map_qiskit_op_to_pyqir_callable("H") + fn2, n2 = map_qiskit_op_to_pyqir_callable("h") + assert fn1 == fn2 + assert n1 == n2 + + +class TestSdkApiContract: + """Verify the API matches what qBraid SDK PR #1132 expects.""" + + def test_qiskit_to_qir_returns_module(self): + circuit = QuantumCircuit(2, 2) + circuit.h(0) + circuit.cx(0, 1) + circuit.measure([0, 1], [0, 1]) + module = qiskit_to_qir(circuit) + assert isinstance(module, pyqir.Module) + + def test_function_accepts_circuit_only(self): + """SDK calls qiskit_to_qir(circuit) with no extra args.""" + circuit = QuantumCircuit(1) + circuit.h(0) + module = qiskit_to_qir(circuit) + assert module is not None diff --git a/tox.ini b/tox.ini index fbd683b2..d640cf7e 100644 --- a/tox.ini +++ b/tox.ini @@ -19,6 +19,7 @@ deps = -r {toxinidir}/requirements-test.txt extras = cirq qasm3 + qiskit squin commands = pytest tests --cov=qbraid_qir --cov-config=pyproject.toml --cov-report=term --cov-report=xml {posargs} @@ -29,6 +30,7 @@ deps = -r {toxinidir}/requirements-test.txt extras = cirq qasm3 + qiskit squin test commands_pre = @@ -42,6 +44,7 @@ deps = -r {toxinidir}/requirements-test.txt extras = cirq qasm3 + qiskit squin test commands_pre =