Source code for qbraid.transpiler.conversions.cirq.cirq_to_pyquil

# 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 function to convert from Cirq's circuit
representation to pyQuil's circuit representation (Quil programs).

"""
from __future__ import annotations

import re
from typing import TYPE_CHECKING

from cirq import Circuit, LineQubit, Moment, QubitOrder
from cirq import ops as cirq_ops
from cirq import parameter_names
from qbraid_core._import import LazyLoader

from qbraid.transpiler.annotations import weight
from qbraid.transpiler.exceptions import ProgramConversionError

try:
    from .cirq_quil_output import QuilOutput
except ImportError:  # pragma: no cover
    QuilOutput = None

pyquil = LazyLoader("pyquil", globals(), "pyquil")

if TYPE_CHECKING:
    import cirq.circuits
    from pyquil import Program

# ``pyquil_to_cirq`` names a slot of a declared register ``name[offset]``, and the bare
# ``name`` when the register holds exactly one value. This recovers the register sizes
# from those symbol names.
_SLOT = re.compile(r"([A-Za-z_]\w*)\[(\d+)\]")


def _declared_registers(circuit: cirq.circuits.Circuit) -> dict[str, int]:
    """Maps each free parameter's register name to the size needed to declare it."""
    sizes: dict[str, int] = {}
    for symbol in sorted(str(name) for name in parameter_names(circuit)):
        match = _SLOT.fullmatch(symbol)
        name, size = (match.group(1), int(match.group(2)) + 1) if match else (symbol, 1)
        sizes[name] = max(sizes.get(name, 0), size)
    return sizes


_BIT_INDEX = re.compile(r"^(?P<register>.+)_(?P<index>\d+)$")


def _classical_bit_order(terminal: list[cirq_ops.Operation]) -> list[cirq_ops.Operation]:
    """Order terminal measurements by the classical bit their key names.

    QASM-derived circuits key single-qubit measurements ``c_0``, ``c_1``, ..., and that
    suffix -- not the moment the operation happens to sit in -- is the bit position the
    result belongs at: ``measure q[2] -> c[0]`` must land q_2 in bit 0 even though a
    measurement on q_0 may appear earlier. Keys without an index fall back to qubit order,
    matching the readout convention the Braket converters document.

    A circuit measuring into multiple registers merges them in register-name order, since
    cirq keys do not record declaration order. ``cirq -> qasm2`` sorts cregs by the same key
    (#1345), so both routes from cirq to pyquil agree on the qubit-to-bit mapping.
    """
    indexed = []
    for op in terminal:
        match = _BIT_INDEX.match(op.gate.key)
        if match is None or len(op.qubits) != 1:
            return sorted(terminal, key=lambda op: min(op.qubits))
        indexed.append(((match["register"], int(match["index"])), op))
    return [op for _, op in sorted(indexed, key=lambda pair: pair[0])]


def _unused_key(existing: set[str], preferred: str = "m") -> str:
    """Return ``preferred``, or a suffixed variant when the circuit already uses it."""
    if preferred not in existing:
        return preferred
    index = 0
    while f"{preferred}_{index}" in existing:
        index += 1
    return f"{preferred}_{index}"


def _merge_terminal_measurements(circuit: cirq.circuits.Circuit) -> cirq.circuits.Circuit:
    """Merge terminal measurements into one keyed measurement operation.

    QASM-derived circuits measure into per-bit keys (``c_0``, ``c_1``, ...), which the
    Quil output would otherwise declare as one ``BIT[1]`` register each -- a fragmented
    form QCS rejects for hardware execution. Mid-circuit measurements are left untouched.

    Moving a terminal measurement past later operations on other qubits is safe: disjoint
    operations commute, and classically controlled operations (the only construct that
    could observe the order) are rejected by ``QuilOutput``.

    Raises:
        ProgramConversionError: If a confusion map is present on a terminal measurement
            that would be merged. A circuit with a single terminal measurement is returned
            unchanged and is never checked.
    """
    operations = list(circuit.all_operations())
    last_op_on_qubit = {}
    for op in operations:
        for qubit in op.qubits:
            last_op_on_qubit[qubit] = op
    terminal = [
        op
        for op in operations
        if isinstance(op.gate, cirq_ops.MeasurementGate)
        and all(last_op_on_qubit[q] is op for q in op.qubits)
    ]
    if len(terminal) <= 1:
        return circuit

    for op in terminal:
        if op.gate.confusion_map:
            raise ProgramConversionError(
                "Cirq measurement confusion maps (readout error matrices) have no Quil "
                "equivalent and cannot be merged into a single readout register."
            )

    qubits, invert_mask = [], []
    for op in _classical_bit_order(terminal):
        mask = op.gate.invert_mask + (False,) * (len(op.qubits) - len(op.gate.invert_mask))
        qubits.extend(op.qubits)
        invert_mask.extend(mask)

    remaining = [op for op in operations if not any(op is t for t in terminal)]
    # A mid-circuit measurement may already hold the preferred key; reusing it would emit a
    # circuit with duplicate measurement keys, which cirq rejects on simulation.
    key = _unused_key(
        {op.gate.key for op in remaining if isinstance(op.gate, cirq_ops.MeasurementGate)}
    )
    merged = cirq_ops.MeasurementGate(
        num_qubits=len(qubits), key=key, invert_mask=tuple(invert_mask)
    ).on(*qubits)
    return Circuit([*remaining, Moment(merged)])


# Both readout defects that forced this edge below the break-even are fixed: fragmented
# BIT[1] registers by ``_merge_terminal_measurements``, and the non-``ro`` register name by
# ``QuilOutput``, which Azure's Rigetti targets reject outright. What is left is gate
# coverage -- 36/38 on the braket gate set against 38/38 via qasm2, the shortfall being
# GPi/GPi2, which convert correctly but as DEFGATEs that quilc has to decompose.
# See tests/transpiler/test_measurement_coverage.py.
[docs] @weight(0.95) def cirq_to_pyquil(circuit: cirq.circuits.Circuit) -> Program: """Returns a pyQuil Program equivalent to the input Cirq circuit. Args: circuit: Cirq circuit to convert to a pyQuil Program. Returns: pyquil.Program object equivalent to the input Cirq circuit. """ circuit = _merge_terminal_measurements(circuit) input_qubits = circuit.all_qubits() max_qubit = max(input_qubits) # if we are using LineQubits, keep the qubit labeling the same if isinstance(max_qubit, LineQubit): qubit_range = max_qubit.x + 1 qubit_order = LineQubit.range(qubit_range) # otherwise, use the default ordering (starting from zero) else: qubit_order = QubitOrder.DEFAULT qubits = QubitOrder.as_qubit_order(qubit_order).order_for(input_qubits) operations = circuit.all_operations() try: quil_str = str(QuilOutput(operations, qubits)) program = pyquil.Program(quil_str) except ValueError as err: raise ProgramConversionError( f"Cirq qasm converter doesn't yet support {err.args[0][32:]}." ) from err # A free parameter appears in the gate lines but has no ``DECLARE``, so the emitted # program is not valid Quil on its own and does not survive a round trip: re-parsing # leaves the register undeclared, and ``pyquil_to_cirq``'s size lookup then reads # slot 0 as the bare register name -- so ``thetas[0]`` comes back as ``thetas`` while # ``thetas[1]`` keeps its index, naming one register two ways. declarations = pyquil.Program() for name, size in _declared_registers(circuit).items(): declarations.declare(name, "REAL", size) return declarations + program if declarations else program