Source code for qc_executor.pauli_propagation.pauli_propagation_circuit

"""Pauli propagation native circuit datatype."""

from __future__ import annotations

from typing import Any, Dict, FrozenSet, List, Sequence, cast

import sympy as sp

from qc_executor.base.circuit_base import QuantumCircuitBase
from qc_executor.base.circuit_ir import CircuitIR, Instruction
from qc_executor.base.gate_set import GATE_DEFS, OpCode
from qc_executor.parameters import Parameter

from .utils.gates import CliffordGate, Gate, LayerBarrier, PauliRotation

#: Opcodes the propagation engine executes directly.  Everything else is lowered
#: into these by the shared decomposition pass, which is how this backend gained
#: support for gates it used to reject outright (crx, cry, crz, rzx, ecr, ccx,
#: cswap, iswap, u, ...).
_SUPPORTED = frozenset(
    {
        OpCode.H,
        OpCode.S,
        OpCode.X,
        OpCode.Y,
        OpCode.Z,
        OpCode.CX,
        OpCode.CZ,
        OpCode.SWAP,
        OpCode.RX,
        OpCode.RY,
        OpCode.RZ,
        OpCode.RXX,
        OpCode.RYY,
        OpCode.RZZ,
        OpCode.BARRIER,
    }
)

#: Opcode -> the engine's Clifford gate name.
_CLIFFORD_NAMES: Dict[OpCode, str] = {
    OpCode.H: "H",
    OpCode.S: "S",
    OpCode.X: "X",
    OpCode.Y: "Y",
    OpCode.Z: "Z",
    OpCode.CX: "CNOT",
    OpCode.CZ: "CZ",
    OpCode.SWAP: "SWAP",
}


def _qubit_arg(qubits: Sequence[int]) -> int | List[int]:
    """Return the single qubit index or the full list, matching gate constructors."""
    return list(qubits) if len(qubits) > 1 else qubits[0]


def _clone_gate(gate: Gate | LayerBarrier, num_qubits: int) -> Gate | LayerBarrier | None:
    """Create a fresh copy of a gate instruction (None for unknown gate types)."""
    if isinstance(gate, LayerBarrier):
        return LayerBarrier()
    if isinstance(gate, PauliRotation):
        return PauliRotation(
            list(gate.symbols),
            _qubit_arg(gate.qubits),
            num_qubits,
            param_expr=gate.param_expr,
            param_value=gate.param_value,
        )
    if isinstance(gate, CliffordGate):
        return CliffordGate(gate.gate_type, _qubit_arg(gate.qubits), num_qubits)
    return None


def _split_angle(angle: Any) -> tuple[sp.Expr | None, float | None]:
    """Split an IR angle into the engine's symbolic / concrete pair.

    Args:
        angle: A number or a SymPy expression.

    Returns:
        ``(expression, None)`` for a symbolic angle, ``(None, value)`` otherwise.
    """
    if isinstance(angle, sp.Basic) and angle.free_symbols:
        return angle, None
    return None, float(angle)


[docs] class PauliPropagationCircuit(QuantumCircuitBase): """Backend-native circuit representation for Pauli propagation. Instructions are recorded in the shared circuit IR and compiled into the engine's flat gate list on demand. Building the gate list from the IR is what removed this backend's dependency on Qiskit for circuit conversion. Args: num_qubits: Number of qubits in the circuit. num_clbits: Number of classical bits (unused; measurement is not representable in the Heisenberg picture). gates: Adopt this gate list directly instead of compiling one. Used by :meth:`replace_gate` and by callers holding engine gates already. _ir: Adopt this instruction store instead of starting empty. """ def __init__( self, num_qubits: int = 0, num_clbits: int = 0, *, gates: Sequence[Gate | LayerBarrier] | None = None, _ir: CircuitIR | None = None, ): if gates is not None and _ir is None and num_qubits == 0: raise ValueError("num_qubits is required when constructing from a gate list") super().__init__(num_qubits, num_clbits, _ir=_ir) self._gates_override: List[Gate | LayerBarrier] | None = ( list(gates) if gates is not None else None ) # ------------------------------------------------------------------ # Backend hooks # ------------------------------------------------------------------
[docs] @classmethod def supported_opcodes(cls) -> FrozenSet[OpCode]: """Return the opcodes the propagation engine executes directly.""" return _SUPPORTED
def _build_native(self) -> List[Gate | LayerBarrier]: """Compile the instruction store into the engine's gate list. The store is lowered first: the shared builder accepts the whole gate set, so a circuit built directly through it may hold gates the engine cannot execute (``sdag``, ``cy``, ``crz``, ...). """ if self._gates_override is not None: return self._gates_override return [self._to_gate(instruction) for instruction in self._lowered_ir()] def _to_gate(self, instruction: Instruction) -> Gate | LayerBarrier: """Translate one instruction into an engine gate. Raises: NotImplementedError: For classically conditioned instructions, which the Heisenberg picture cannot express. """ opcode = instruction.opcode if instruction.condition is not None: # Without this the gate would be applied unconditionally, which is a # wrong answer rather than a missing feature. raise NotImplementedError( "Classically conditioned gates are not supported by the " "Pauli-propagation backend: it propagates operators in the " "Heisenberg picture, where no measurement outcome exists to " f"condition '{instruction.name}' on." ) if opcode is OpCode.BARRIER: return LayerBarrier() clifford = _CLIFFORD_NAMES.get(opcode) if clifford is not None: return CliffordGate(clifford, _qubit_arg(instruction.qubits), self.num_qubits) generators = GATE_DEFS[opcode].pauli_rotation if generators is None: raise NotImplementedError(f"'{instruction.name}' has no Pauli-propagation equivalent") expression, value = _split_angle(instruction.params[0]) return PauliRotation( list(generators), _qubit_arg(instruction.qubits), self.num_qubits, param_expr=expression, param_value=value, ) # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @property def gates(self) -> List[Gate | LayerBarrier]: """Return a shallow copy of the engine gate instructions.""" return list(self.native) @property def parameter_symbols(self) -> Dict[str, Parameter]: """Return the parameters keyed by name, e.g. ``{"theta[0]": theta[0]}``.""" if self._gates_override is not None: found: Dict[str, Parameter] = {} for gate in self._gates_override: if isinstance(gate, PauliRotation) and gate.param_expr is not None: for symbol in gate.param_expr.free_symbols: found.setdefault(symbol.name, symbol) return found return {parameter.name: parameter for parameter in self.parameters} @property def parameter_names(self) -> List[str]: """Return the parameter names used by the circuit.""" return list(self.parameter_symbols) @property def parameters(self) -> List[Parameter]: """Return the free parameters, sorted by ``(vector_name, index)``.""" if self._gates_override is not None: from qc_executor.parameters import ( # pylint: disable=import-outside-toplevel sort_parameters, ) return sort_parameters(self.parameter_symbols.values()) return super().parameters
[docs] def draw(self) -> str: """Return a plain-text listing of the engine gate sequence.""" return "\n".join(str(gate) for gate in self.native)
# ------------------------------------------------------------------ # Structure # ------------------------------------------------------------------
[docs] def replace_gate(self, index: int, gate: Gate | LayerBarrier) -> "PauliPropagationCircuit": """Return a copy with the gate at ``index`` replaced. Used by the parameter-shift rule, which needs one rotation's angle displaced while everything else stays put. The result carries an explicit gate list, because a shifted gate need not correspond to any instruction in the original store. Args: index: Position of the gate to replace. gate: The replacement. Returns: A new circuit; this one is untouched. Raises: IndexError: If ``index`` is out of range. """ gates = self.gates if not 0 <= index < len(gates): raise IndexError(f"gate index {index} out of range for {len(gates)} gate(s)") gates[index] = gate return type(self)(self.num_qubits, gates=gates)
[docs] def assign_parameters(self, parameters: Dict[Any, float]) -> "PauliPropagationCircuit": """Return a copy with values substituted for parameters. Only the adopted-gate-list case needs this override; a circuit backed by the instruction store binds through the base implementation. Args: parameters: Values keyed by :class:`~qc_executor.parameters.Parameter` or by name. Returns: A new bound circuit; this one is untouched. """ if self._gates_override is None: return cast("PauliPropagationCircuit", super().assign_parameters(parameters)) binding = { (key if isinstance(key, Parameter) else Parameter(str(key))): value for key, value in parameters.items() } bound: List[Gate | LayerBarrier] = [] for gate in self._gates_override: if isinstance(gate, PauliRotation) and gate.param_expr is not None: result = gate.param_expr.xreplace(dict(binding)) expression, value = _split_angle(result) bound.append( PauliRotation( list(gate.symbols), _qubit_arg(gate.qubits), self.num_qubits, param_expr=expression, param_value=value, ) ) else: cloned = _clone_gate(gate, self.num_qubits) if cloned is not None: bound.append(cloned) return type(self)(self.num_qubits, gates=bound)
[docs] def copy(self) -> "PauliPropagationCircuit": """Return an independent copy of this circuit.""" if self._gates_override is not None: return type(self)( self.num_qubits, gates=[ cloned for cloned in ( _clone_gate(gate, self.num_qubits) for gate in self._gates_override ) if cloned is not None ], ) return type(self)(self.num_qubits, self.num_clbits, _ir=self._ir.copy())
[docs] def measure(self, qubits=None, clbits=None): """Measurement is not representable in the Heisenberg picture.""" raise NotImplementedError("Measurement is not represented in PauliPropagationCircuit.")
[docs] def from_qasm(self, qasm: str) -> None: """QASM import is not supported for this datatype.""" raise NotImplementedError("QASM import is intentionally not supported for this datatype.")
[docs] def to_qasm(self) -> str: """QASM export is not supported for this datatype.""" raise NotImplementedError("QASM export is intentionally not supported for this datatype.")
[docs] def circuit_metrics(self) -> dict: """Return gate counts and depth for the compiled gate list.""" gates = self.native gate_count = sum(1 for gate in gates if not isinstance(gate, LayerBarrier)) depth = gate_count return { "num_qubits": self.num_qubits, "num_gates": gate_count, "depth": depth, "num_parameters": self.num_parameters, }
@property def num_parameters(self) -> int: """Number of free parameters in the circuit.""" if self._gates_override is not None: return len(self.parameter_symbols) return super().num_parameters @property def is_parameterized(self) -> bool: """Whether the circuit has any free parameter.""" return self.num_parameters > 0 def __hash__(self) -> int: if self._gates_override is None: return super().__hash__() return hash( ( self.num_qubits, tuple( ( type(gate).__name__, getattr(gate, "gate_type", None), tuple(getattr(gate, "symbols", ()) or ()), tuple(getattr(gate, "qubits", ()) or ()), str(getattr(gate, "param_expr", None)), getattr(gate, "param_value", None), ) for gate in self._gates_override ), ) ) def __str__(self) -> str: return self.draw() def __repr__(self) -> str: return ( f"{type(self).__name__}(num_qubits={self.num_qubits}, " f"num_gates={len(self.native)})" )