Source code for qbraid.transpiler.conversions.openqasm3.openqasm3_to_ionq

# Copyright 2025 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 containing OpenQASM to IonQ JSON conversion function

"""
from __future__ import annotations

import re
import warnings
from typing import TYPE_CHECKING, Any, Union

import openqasm3.ast

from qbraid.passes.qasm.compat import convert_qasm_pi_to_decimal
from qbraid.programs import load_program
from qbraid.programs.gate_model.ionq import IONQ_NATIVE_GATES, IonQProgram
from qbraid.programs.gate_model.qasm2 import OpenQasm2Program
from qbraid.programs.gate_model.qasm3 import OpenQasm3Program
from qbraid.transpiler.annotations import weight
from qbraid.transpiler.exceptions import ProgramConversionError

if TYPE_CHECKING:
    from qbraid.programs.typer import IonQDictType, QasmStringType

ONE_QUBIT_NON_PARAM = {
    "x": "x",
    "not": "not",
    "y": "y",
    "z": "z",
    "h": "h",
    "s": "s",
    "si": "si",
    "t": "t",
    "ti": "ti",
    "v": "v",
    "vi": "vi",
    "sdg": "si",
    "tdg": "ti",
    "sx": "v",
    "sxdg": "vi",
}

ONE_QUBIT_PARAM_ROT = {
    "p": "rz",
    "rx": "rx",
    "ry": "ry",
    "rz": "rz",
}

ONE_QUBIT_PARAM_PHASE = {
    "gpi": "gpi",
    "gpi2": "gpi2",
}


IONQ_ONE_QUBIT_GATE_MAP = {**ONE_QUBIT_NON_PARAM, **ONE_QUBIT_PARAM_ROT, **ONE_QUBIT_PARAM_PHASE}

TWO_QUBIT_NON_PARAM = {
    "cnot": "cnot",
    "cx": "cnot",
    "swap": "swap",
}

TWO_QUBIT_PARAM_ANGLE = {
    "zz": "zz",
    "rzz": "zz",
}

TWO_QUBIT_PARAM_ANGLE_PHASE = {
    "ms": "ms",
}

IONQ_TWO_QUBIT_GATE_MAP = {
    **TWO_QUBIT_NON_PARAM,
    **TWO_QUBIT_PARAM_ANGLE,
    **TWO_QUBIT_PARAM_ANGLE_PHASE,
}

IONQ_THREE_QUBIT_GATE_MAP = {
    "ccnot": "cnot",
    "ccx": "cnot",
    "toffoli": "cnot",
}

IONQ_THREE_QUBIT_GATE_ALIASES = {
    "ccnot": "ccnot",
    "ccx": "ccnot",
    "toffoli": "ccnot",
}


def extract_params(statement: openqasm3.ast.QuantumGate) -> list[str]:
    """Extracts the parameter(s) from a QuantumGate statement.

    Args:
        statement (openqasm3.ast.QuantumGate): QuantumGate statement to extract parameter(s) from.

    Returns:
        Union[str, list[str]]: Parameter(s) extracted from the QuantumGate statement.
    """
    try:
        params: str = re.findall(r"\((.+)\)", openqasm3.dumps(statement))[0]
    except IndexError:
        return []

    return [p.strip(" ") for p in params.split(",")]


def _parse_float_in_range(
    value: str, gate_name: str, param_name: str, bounds: tuple[float, float]
) -> float:

    min_val, max_val = bounds

    err_msg = (
        f"Invalid {param_name} value '{value}' for the '{gate_name}' gate. "
        f"{param_name.capitalize()} must be a float between {min_val} and {max_val}."
    )
    try:
        value = float(value)
    except ValueError as err:
        raise ValueError(err_msg) from err

    if not min_val <= value <= max_val:
        raise ValueError(err_msg)

    return value


def _parse_phase(phase: str, gate_name: str) -> float:
    return _parse_float_in_range(phase, gate_name, "phase", (-1, 1))


def _parse_angle(angle: str, gate_name: str) -> float:
    return _parse_float_in_range(angle, gate_name, "angle", (0, 0.25))


def _register_offsets(
    program: openqasm3.ast.Program, register_sizes: dict[str, int]
) -> dict[str, int]:
    """Map each qubit register name to the IonQ index of its first qubit.

    IonQ numbers qubits across the whole program, so each register starts where the
    previously declared registers end. Sizes come from pyqasm when it has validated the
    program, and from the declaration itself otherwise.
    """
    offsets: dict[str, int] = {}
    offset = 0
    for statement in program.statements:
        if not isinstance(statement, openqasm3.ast.QubitDeclaration):
            continue
        name = statement.qubit.name
        offsets[name] = offset
        if name in register_sizes:
            offset += register_sizes[name]
        elif statement.size is None:
            offset += 1
        elif isinstance(statement.size, openqasm3.ast.IntegerLiteral):
            offset += statement.size.value
        else:
            # A const- or expression-sized register (e.g. `qubit[n] b;`) whose size
            # pyqasm hasn't resolved for us. This surfaces when an earlier statement
            # failed to unroll, so `register_sizes` was never populated for it.
            raise ValueError(
                f"cannot determine the size of qubit register '{name}': its declared size "
                "is not a literal integer and pyqasm has not resolved it. This usually means "
                "an earlier statement failed to unroll; fix that error first."
            )
    return offsets


# pylint: disable-next=too-many-statements
def _parse_gates(program: Union[OpenQasm2Program, OpenQasm3Program]) -> list[dict[str, Any]]:
    program_qubits = program.module._qubit_registers.items()

    original = program.module.original_program

    # Use the original AST when it contains top-level gate operations (the common case).
    # Fall back to the unrolled AST only when gates are hidden inside compound
    # statements (for-loops, subroutine calls, etc.) and thus invisible at the top level.
    has_top_level_gates = any(isinstance(s, openqasm3.ast.QuantumGate) for s in original.statements)
    if has_top_level_gates:
        ast_program = original
    else:
        unrolled = program.module.unrolled_ast
        if unrolled is not None and len(unrolled.statements) > 0:
            ast_program = unrolled
        else:
            ast_program = original

    register_offsets = _register_offsets(ast_program, dict(program_qubits))

    gates: list[dict[str, Any]] = []

    contains_native = False
    non_zz_native_gates = set(IONQ_NATIVE_GATES) - {"zz"}

    # pylint: disable-next=too-many-nested-blocks
    for statement in ast_program.statements:
        if isinstance(statement, openqasm3.ast.QuantumGate):
            name = statement.name.name.lower()

            if name == "id":
                continue

            contains_native = (
                contains_native
                or name in non_zz_native_gates
                or (name.startswith("c") and name[1:] in non_zz_native_gates)
            )

            qubits = statement.qubits
            qubit_values = []

            # IonQ addresses a logical register and places the circuit itself, so a
            # hardware qubit ($0) has nothing to map onto. qiskit>=2 emits these once a
            # circuit is laid out on a backend's physical qubits.
            for qubit in qubits:
                if isinstance(qubit, openqasm3.ast.Identifier) and qubit.name.startswith("$"):
                    raise ValueError(
                        f"Hardware qubit '{qubit.name}' is not supported by the IonQ format, "
                        "which addresses a logical register. Convert the circuit before it is "
                        "laid out on a backend's physical qubits."
                    )

            if len(qubits) == 1 and isinstance(qubits[0], openqasm3.ast.Identifier):
                reg_name = qubits[0].name
                for qreg_name, reg_size in program_qubits:
                    if qreg_name == reg_name:
                        start = register_offsets[qreg_name]
                        qubit_values = list(range(start, start + reg_size))
                        break
                else:
                    # Without this the loop falls through with an empty qubit_values
                    # and the gate is dropped, producing a circuit that differs from
                    # the program with nothing to signal it.
                    raise ValueError(
                        f"qubit register '{reg_name}' used by gate '{name}' has no "
                        "matching declaration. This can happen for an alias (e.g. "
                        "'let a = q[0:1];') -- IonQ conversion only supports qubits "
                        "declared directly with 'qubit[n] name;'."
                    )
            else:
                for qubit in qubits:
                    if not isinstance(qubit, openqasm3.ast.IndexedIdentifier):
                        raise ValueError(
                            f"gate '{name}' addresses register "
                            f"'{getattr(qubit, 'name', qubit)}' without an index; "
                            "broadcasting a multi-qubit gate across a whole register "
                            "is not supported."
                        )
                    reg_name = qubit.name.name
                    if reg_name not in register_offsets:
                        raise ValueError(
                            f"qubit register '{reg_name}' used by gate '{name}' has no "
                            "matching declaration. This can happen for an alias (e.g. "
                            "'let a = q[0:1];') -- IonQ conversion only supports qubits "
                            "declared directly with 'qubit[n] name;'."
                        )
                    start = register_offsets[reg_name]
                    indices = qubit.indices
                    for index in indices:
                        qubit_values.extend(start + literal.value for literal in index)

            if name in IONQ_ONE_QUBIT_GATE_MAP:
                ionq_name = IONQ_ONE_QUBIT_GATE_MAP[name]
                if ionq_name in ONE_QUBIT_PARAM_ROT:
                    try:
                        angle: str = extract_params(statement)[0]
                    except IndexError as err:
                        raise ValueError(
                            f"Rotation parameter is required for the '{name}' "
                            "gate but was not provided."
                        ) from err
                    angle_decimal = float(convert_qasm_pi_to_decimal(angle))
                    for qubit in qubit_values:
                        gates.append(
                            {
                                "gate": ionq_name,
                                "target": qubit,
                                "rotation": angle_decimal,
                            }
                        )
                elif ionq_name in ONE_QUBIT_PARAM_PHASE:
                    try:
                        phase: str = extract_params(statement)[0]
                    except IndexError as err:
                        raise ValueError(
                            f"Phase parameter is required for the '{name}' "
                            "gate but was not provided."
                        ) from err
                    phase = _parse_phase(phase, ionq_name)

                    for qubit in qubit_values:
                        gates.append(
                            {
                                "gate": ionq_name,
                                "target": qubit,
                                "phase": phase,
                            }
                        )
                else:
                    for qubit in qubit_values:
                        gates.append({"gate": ionq_name, "target": qubit})

            elif name in IONQ_TWO_QUBIT_GATE_MAP:
                ionq_name = IONQ_TWO_QUBIT_GATE_MAP[name]

                if len(qubit_values) != 2:
                    raise ValueError(
                        f"Invalid number of qubits for the '{name}' gate. "
                        f"Expected 2, got {len(qubit_values)}"
                    )

                if ionq_name in TWO_QUBIT_PARAM_ANGLE:
                    try:
                        angle = extract_params(statement)[0]
                    except IndexError as err:
                        raise ValueError(
                            f"Angle parameter is required for the '{name}' "
                            "gate but was not provided."
                        ) from err

                    # Treat zz as 'qis' gate if all other gates are 'qis' gates
                    if name == "rzz" or (
                        ionq_name == "zz" and len(gates) > 0 and contains_native is False
                    ):
                        angle = float(convert_qasm_pi_to_decimal(angle))
                        gates.append(
                            {
                                "gate": ionq_name,
                                "rotation": angle,
                                "targets": qubit_values,
                            }
                        )

                    else:
                        key = "angle"

                        try:
                            angle = _parse_angle(angle, ionq_name)
                        except ValueError as err:
                            #  Treat zz with angle not in [0, 0.25] as 'qis'
                            if ionq_name == "zz" and contains_native is False:
                                key = "rotation"

                                try:
                                    angle = float(convert_qasm_pi_to_decimal(angle))
                                except ValueError:  # pylint: disable=raise-missing-from
                                    raise err
                            else:
                                raise err
                        else:
                            if ionq_name == "zz":
                                contains_native = True

                        gates.append(
                            {
                                "gate": ionq_name,
                                key: angle,
                                "targets": qubit_values,
                            }
                        )

                elif ionq_name in TWO_QUBIT_PARAM_ANGLE_PHASE:
                    params = extract_params(statement)
                    if len(params) not in {2, 3}:  # pragma: no cover
                        raise ValueError(
                            f"Invalid number of parameters for the '{name}' gate. "
                            f"Expected 2 or 3, got {len(params)}"
                        )

                    phases = [
                        _parse_phase(param, ionq_name) if i < 2 else _parse_phase(param, ionq_name)
                        for i, param in enumerate(params[:2])
                    ]
                    angle = _parse_angle(params[2], ionq_name) if len(params) == 3 else None

                    gate_data = {
                        "gate": ionq_name,
                        "phases": phases,
                        "targets": qubit_values,
                        **({"angle": angle} if angle is not None else {}),
                    }
                    gates.append(gate_data)

                elif ionq_name.startswith("c"):
                    gates.append(
                        {
                            "gate": ionq_name,
                            "control": qubit_values[0],
                            "target": qubit_values[1],
                        }
                    )
                else:
                    gates.append({"gate": ionq_name, "targets": qubit_values})

            elif name.startswith("c") and name[1:] in IONQ_ONE_QUBIT_GATE_MAP:
                ionq_name = IONQ_ONE_QUBIT_GATE_MAP[name[1:]]

                if len(qubit_values) != 2:
                    raise ValueError(
                        f"Invalid number of qubits for the '{name}' gate. "
                        f"Expected 2, got {len(qubit_values)}"
                    )

                if ionq_name in ONE_QUBIT_PARAM_ROT:
                    try:
                        angle: str = extract_params(statement)[0]
                    except IndexError as err:
                        raise ValueError(
                            f"Rotation parameter is required for the '{name}' "
                            "gate but was not provided."
                        ) from err
                    angle_decimal = float(convert_qasm_pi_to_decimal(angle))

                    gates.append(
                        {
                            "gate": ionq_name,
                            "control": qubit_values[0],
                            "target": qubit_values[1],
                            "rotation": angle_decimal,
                        }
                    )
                elif ionq_name in ONE_QUBIT_PARAM_PHASE:
                    try:
                        phase: str = extract_params(statement)[0]
                    except IndexError as err:
                        raise ValueError(
                            f"Phase parameter is required for the '{name}' "
                            "gate but was not provided."
                        ) from err
                    phase = _parse_phase(phase, ionq_name)

                    gates.append(
                        {
                            "gate": ionq_name,
                            "control": qubit_values[0],
                            "target": qubit_values[1],
                            "phase": phase,
                        }
                    )
                else:
                    gates.append(
                        {"gate": ionq_name, "control": qubit_values[0], "target": qubit_values[1]}
                    )

            elif name in IONQ_THREE_QUBIT_GATE_MAP:
                ionq_name = IONQ_THREE_QUBIT_GATE_MAP[name]

                if len(qubit_values) != 3:
                    raise ValueError(
                        f"Invalid number of qubits for the '{name}' gate. "
                        f"Expected 3, got {len(qubit_values)}"
                    )
                gates.append(
                    {
                        "gate": ionq_name,
                        "controls": qubit_values[:2],
                        "target": qubit_values[2],
                    }
                )

            else:
                raise ValueError(f"Gate '{name}' not supported by IonQ")

    return gates


[docs] @weight(1) def openqasm3_to_ionq(qasm: Union[QasmStringType, openqasm3.ast.Program]) -> IonQDictType: """Returns an IonQ JSON format representation the input OpenQASM program. Args: qasm (str or openqasm3.ast.Program): OpenQASM program to convert to IonQDict type. Returns: dict: IonQ JSON format equivalent to input OpenQASM string. Raises: ProgramConversionError: For failure to parse gate data from OpenQASM string. """ program: Union[OpenQasm2Program, OpenQasm3Program] = load_program(qasm) # Unroll compound statements (for-loops, custom gates, etc.) so that # _parse_gates can see all individual gate operations at the top level. try: program.module.unroll() except Exception: # pylint: disable=broad-exception-caught pass if program._module.has_measurements(): warnings.warn( "Circuit contains measurement gates, which will be ignored " "during conversion to the IonQDictType", UserWarning, ) gates = _parse_gates(program) if len(gates) == 0: raise ProgramConversionError( "No gate operations found in OpenQASM program. " "Ensure the circuit contains supported gate operations." ) gateset = IonQProgram.determine_gateset(gates) return { "qubits": program.num_qubits, "circuit": gates, "gateset": gateset.value, "format": "ionq.circuit.v0", }