Source code for qc_executor.pauli_propagation.pauli_propagation_circuit

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

from __future__ import annotations

from typing import Any, Dict, List, Sequence

import numpy as np
import sympy as sp

from qc_executor.base.circuit_base import QuantumCircuitBase
from qc_executor.utils.qiskit_compat import _param_is_constant, _param_to_float, _param_to_sympy

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


def _qubit_arg(qubits: List[int]) -> int | List[int]:
    """Return the single qubit index or the full list, matching gate constructors."""
    return 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 _record_symbols(param_expression: sp.Expr | None, parameters: Dict[str, sp.Symbol]) -> None:
    """Record all free symbols of an expression in a name -> symbol mapping."""
    if param_expression is None:
        return
    for symbol in param_expression.free_symbols:
        parameters.setdefault(symbol.name, symbol)


[docs] class PauliPropagationCircuit(QuantumCircuitBase): """Backend-native circuit representation for Pauli propagation. This datatype stores operations directly in the internal gate representation used by the propagation engine and does not depend on Qiskit objects. """ def __init__( self, num_qubits: int, *, gates: Sequence[Gate | LayerBarrier] | None = None, parameter_symbols: Dict[str, sp.Symbol] | None = None, ): super().__init__(num_qubits) self._gates: List[Gate | LayerBarrier] = list(gates) if gates is not None else [] # Maps parameter names to sympy symbols self._parameters: Dict[str, sp.Symbol] = ( dict(parameter_symbols) if parameter_symbols is not None else {} )
[docs] @classmethod def from_quantum_circuit(cls, circuit: QuantumCircuitBase) -> "PauliPropagationCircuit": """Create a PauliPropagationCircuit from a generic circuit.""" if isinstance(circuit, cls): return circuit if not hasattr(circuit, "qiskit_circuit"): raise TypeError( "PauliPropagationCircuit.from_quantum_circuit expects a generic QuantumCircuit " f"or {cls.__name__}, got {type(circuit).__name__}" ) # pylint: disable-next=import-outside-toplevel from .utils.qiskit_converter import convert_circuit # The wrapped Qiskit circuit is exposed via the public qiskit_circuit # property (hasattr-checked above); getattr keeps the duck typing # opaque to static type checkers. gates = convert_circuit(getattr(circuit, "qiskit_circuit"), use_cache=True) parameters: Dict[str, sp.Symbol] = {} for gate in gates: if isinstance(gate, PauliRotation): _record_symbols(gate.param_expr, parameters) return cls(circuit.num_qubits, gates=gates, parameter_symbols=parameters)
@property def gates(self) -> List[Gate | LayerBarrier]: """Return a shallow copy of gate instructions.""" return list(self._gates) @property def parameters(self) -> List[str]: """Return parameter names used by the circuit.""" return list(self._parameters.keys()) @property def parameter_symbols(self) -> Dict[str, sp.Symbol]: """Return a copy of the parameter-name to sympy-symbol mapping.""" return dict(self._parameters) @property def num_parameters(self) -> int: return len(self._parameters) @property def is_parameterized(self) -> bool: return self.num_parameters > 0
[docs] def draw(self) -> str: return "\n".join(str(g) for g in self._gates)
def _record_parameter(self, param_expression: sp.Expr | None) -> None: """Record sympy symbols from a parameter expression.""" _record_symbols(param_expression, self._parameters) @staticmethod def _extract_parameter(parameter: Any) -> tuple[sp.Expr | None, float | None]: """Extract parameter as sympy expression or concrete float value. Returns: (sympy_expr, None) for parametric gates (None, float_value) for concrete gates """ if isinstance(parameter, (int, float)): return None, float(parameter) # Handle Qiskit ParameterExpression if hasattr(parameter, "parameters"): if _param_is_constant(parameter): return None, _param_to_float(parameter) # Convert to sympy expression return _param_to_sympy(parameter), None # Handle direct sympy expressions if isinstance(parameter, sp.Expr): if parameter.is_number: return None, float(parameter) return parameter, None raise TypeError(f"Unsupported parameter type: {type(parameter)!r}")
[docs] def h(self, qubits: int | List[int]): qubit_list = [qubits] if isinstance(qubits, int) else qubits for qubit in qubit_list: self._gates.append(CliffordGate("H", qubit, self._num_qubits))
[docs] def s(self, qubits: int | List[int]): qubit_list = [qubits] if isinstance(qubits, int) else qubits for qubit in qubit_list: self._gates.append(CliffordGate("S", qubit, self._num_qubits))
[docs] def sdag(self, qubits: int | List[int]): self.rz(qubits, -np.pi / 2)
[docs] def t(self, qubits: int | List[int]): # T equals RZ(π/4) up to a global phase, which cancels in Heisenberg # conjugation; the rotation is exact (T is not a Clifford gate) self.rz(qubits, np.pi / 4)
[docs] def tdag(self, qubits: int | List[int]): self.rz(qubits, -np.pi / 4)
[docs] def p(self, qubits: int | List[int], angle: float): self.rz(qubits, angle)
[docs] def x(self, qubits: int | List[int]): qubit_list = [qubits] if isinstance(qubits, int) else qubits for qubit in qubit_list: self._gates.append(CliffordGate("X", qubit, self._num_qubits))
[docs] def y(self, qubits: int | List[int]): qubit_list = [qubits] if isinstance(qubits, int) else qubits for qubit in qubit_list: self._gates.append(CliffordGate("Y", qubit, self._num_qubits))
[docs] def z(self, qubits: int | List[int]): qubit_list = [qubits] if isinstance(qubits, int) else qubits for qubit in qubit_list: self._gates.append(CliffordGate("Z", qubit, self._num_qubits))
[docs] def rx(self, qubits: int | List[int], angle: float): qubit_list = [qubits] if isinstance(qubits, int) else qubits for qubit in qubit_list: param_expr, param_value = self._extract_parameter(angle) self._record_parameter(param_expr) self._gates.append( PauliRotation( ["X"], qubit, self._num_qubits, param_expr=param_expr, param_value=param_value, ) )
[docs] def ry(self, qubits: int | List[int], angle: float): qubit_list = [qubits] if isinstance(qubits, int) else qubits for qubit in qubit_list: param_expr, param_value = self._extract_parameter(angle) self._record_parameter(param_expr) self._gates.append( PauliRotation( ["Y"], qubit, self._num_qubits, param_expr=param_expr, param_value=param_value, ) )
[docs] def rz(self, qubits: int | List[int], angle: float): qubit_list = [qubits] if isinstance(qubits, int) else qubits for qubit in qubit_list: param_expr, param_value = self._extract_parameter(angle) self._record_parameter(param_expr) self._gates.append( PauliRotation( ["Z"], qubit, self._num_qubits, param_expr=param_expr, param_value=param_value, ) )
[docs] def cx(self, control_qubit: int, target_qubit: int): self._gates.append(CliffordGate("CNOT", [control_qubit, target_qubit], self._num_qubits))
[docs] def cy(self, control_qubit: int, target_qubit: int): self.sdag(target_qubit) self.cx(control_qubit, target_qubit) self.s(target_qubit)
[docs] def cz(self, control_qubit: int, target_qubit: int): self._gates.append(CliffordGate("CZ", [control_qubit, target_qubit], self._num_qubits))
[docs] def crx(self, control_qubit: int, target_qubit: int, angle: float): raise NotImplementedError("CRX is not yet supported in PauliPropagationCircuit.")
[docs] def cry(self, control_qubit: int, target_qubit: int, angle: float): raise NotImplementedError("CRY is not yet supported in PauliPropagationCircuit.")
[docs] def crz(self, control_qubit: int, target_qubit: int, angle: float): raise NotImplementedError("CRZ is not yet supported in PauliPropagationCircuit.")
[docs] def rxx(self, control_qubit: int, target_qubit: int, angle: float): param_expr, param_value = self._extract_parameter(angle) self._record_parameter(param_expr) self._gates.append( PauliRotation( ["X", "X"], [control_qubit, target_qubit], self._num_qubits, param_expr=param_expr, param_value=param_value, ) )
[docs] def ryy(self, control_qubit: int, target_qubit: int, angle: float): param_expr, param_value = self._extract_parameter(angle) self._record_parameter(param_expr) self._gates.append( PauliRotation( ["Y", "Y"], [control_qubit, target_qubit], self._num_qubits, param_expr=param_expr, param_value=param_value, ) )
[docs] def rzz(self, control_qubit: int, target_qubit: int, angle: float): param_expr, param_value = self._extract_parameter(angle) self._record_parameter(param_expr) self._gates.append( PauliRotation( ["Z", "Z"], [control_qubit, target_qubit], self._num_qubits, param_expr=param_expr, param_value=param_value, ) )
[docs] def rzx(self, control_qubit: int, target_qubit: int, angle: float): raise NotImplementedError("RZX is not yet supported in PauliPropagationCircuit.")
[docs] def swap(self, qubit1: int, qubit2: int): self._gates.append(CliffordGate("SWAP", [qubit1, qubit2], self._num_qubits))
[docs] def barrier(self, qubits: int | List[int]): self._gates.append(LayerBarrier())
[docs] def measure(self): raise NotImplementedError("Measurement is not represented in PauliPropagationCircuit.")
[docs] def compose(self, qc: "QuantumCircuitBase", qubits: List[int]) -> "PauliPropagationCircuit": if not isinstance(qc, PauliPropagationCircuit): raise TypeError("compose currently supports PauliPropagationCircuit only.") if len(qubits) != qc.num_qubits: raise ValueError("Length of qubits mapping must match composed circuit qubit count.") merged_gates = [ cloned for cloned in (_clone_gate(gate, self.num_qubits) for gate in self._gates) if cloned is not None ] merged_parameters = dict(self._parameters) qubit_map = dict(enumerate(qubits)) for gate in qc.gates: if isinstance(gate, LayerBarrier): merged_gates.append(LayerBarrier()) continue remapped_qubits = [qubit_map[q] for q in gate.qubits] if isinstance(gate, PauliRotation): _record_symbols(gate.param_expr, merged_parameters) merged_gates.append( PauliRotation( list(gate.symbols), _qubit_arg(remapped_qubits), self.num_qubits, param_expr=gate.param_expr, param_value=gate.param_value, ) ) elif isinstance(gate, CliffordGate): merged_gates.append( CliffordGate(gate.gate_type, _qubit_arg(remapped_qubits), self.num_qubits) ) return PauliPropagationCircuit( self.num_qubits, gates=merged_gates, parameter_symbols=merged_parameters )
[docs] def assign_parameters(self, parameters: Dict[str, float]) -> "PauliPropagationCircuit": """Bind symbolic parameters to concrete values. Args: parameters: Dict mapping parameter names to float values Returns: New circuit with parameters substituted """ new_gates: List[Gate | LayerBarrier] = [] # Build substitution dict for sympy subs_dict = {} for param_name, param_value in parameters.items(): if param_name in self._parameters: subs_dict[self._parameters[param_name]] = param_value for gate in self._gates: if isinstance(gate, PauliRotation) and gate.param_expr is not None: # Substitute parameters in the expression substituted_expr = gate.param_expr.subs(subs_dict) # If fully evaluated, convert to concrete value if substituted_expr.is_number: new_gates.append( PauliRotation( list(gate.symbols), _qubit_arg(gate.qubits), self.num_qubits, param_expr=None, param_value=float(substituted_expr), ) ) else: # Partially evaluated, keep as expression new_gates.append( PauliRotation( list(gate.symbols), _qubit_arg(gate.qubits), self.num_qubits, param_expr=substituted_expr, param_value=None, ) ) else: cloned = _clone_gate(gate, self.num_qubits) if cloned is not None: new_gates.append(cloned) # Update parameter tracking - remove fully bound parameters remaining_params = { name: symbol for name, symbol in self._parameters.items() if name not in parameters } return PauliPropagationCircuit( self.num_qubits, gates=new_gates, parameter_symbols=remaining_params )
[docs] def invert(self) -> "PauliPropagationCircuit": inverted_gates: List[Gate | LayerBarrier] = [] for gate in reversed(self._gates): if isinstance(gate, LayerBarrier): inverted_gates.append(LayerBarrier()) elif isinstance(gate, PauliRotation): if gate.param_expr is not None: # Negate the symbolic expression inverted_gates.append( PauliRotation( list(gate.symbols), _qubit_arg(gate.qubits), self.num_qubits, param_expr=-gate.param_expr, param_value=None, ) ) else: # Negate the concrete value inverted_gates.append( PauliRotation( list(gate.symbols), _qubit_arg(gate.qubits), self.num_qubits, param_expr=None, param_value=-float(gate.param_value), ) ) elif isinstance(gate, CliffordGate): inverted_gates.append( CliffordGate(gate.gate_type, _qubit_arg(gate.qubits), self.num_qubits) ) return PauliPropagationCircuit( self.num_qubits, gates=inverted_gates, parameter_symbols=self._parameters )
[docs] def copy(self) -> "PauliPropagationCircuit": copied_gates = [ cloned for cloned in (_clone_gate(gate, self.num_qubits) for gate in self._gates) if cloned is not None ] return PauliPropagationCircuit( self.num_qubits, gates=copied_gates, parameter_symbols=self._parameters )
[docs] def replace_gate(self, index: int, gate: Gate | LayerBarrier) -> "PauliPropagationCircuit": """Return a new circuit with the gate at ``index`` replaced. Unreplaced gate objects are shared with this circuit; gates are immutable after construction, so this is safe. """ new_gates = list(self._gates) new_gates[index] = gate return PauliPropagationCircuit( self.num_qubits, gates=new_gates, parameter_symbols=self._parameters )
[docs] def circuit_metrics(self) -> dict: gate_count = sum(1 for gate in self._gates if isinstance(gate, Gate)) depth = gate_count return { "num_qubits": self.num_qubits, "num_gates": gate_count, "depth": depth, "num_parameters": self.num_parameters, }
[docs] def from_qasm(self, qasm: str) -> None: raise NotImplementedError("QASM import is intentionally not supported for this datatype.")
[docs] def to_qasm(self) -> str: raise NotImplementedError("QASM export is intentionally not supported for this datatype.")
def __hash__(self): signature: Sequence[Any] = [] items: List[Any] = [] for gate in self._gates: if isinstance(gate, LayerBarrier): items.append(("BARRIER",)) elif isinstance(gate, CliffordGate): items.append(("CLIFFORD", gate.gate_type, tuple(gate.qubits))) elif isinstance(gate, PauliRotation): # Convert sympy expression to string for hashing param_repr = ( str(gate.param_expr) if gate.param_expr is not None else gate.param_value ) items.append( ( "ROT", tuple(gate.symbols), tuple(gate.qubits), param_repr, ) ) signature = tuple(items) return hash((self.num_qubits, signature)) def __str__(self): return f"PauliPropagationCircuit(num_qubits={self.num_qubits}, gates={len(self._gates)})" def __repr__(self): return self.__str__()