# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See LICENSE.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
r"""Utilities for transpiling qiskit quantum circuits.
This module provides various custom transformation passes for optimizing circuits, including merging Z-basis
rotations, substituting Clifford Rz gates, and removing Z-basis operations on qubits in the :math:`\lvert 0 \rangle`
state. It also includes functions to create custom pass managers based on preset configurations and custom passes.
"""
import numpy as np
from qiskit.circuit import ParameterExpression
from qiskit.circuit.library import (
IGate,
RXGate,
RXXGate,
RYGate,
RYYGate,
RZGate,
RZZGate,
SdgGate,
SGate,
TdgGate,
TGate,
XGate,
YGate,
ZGate,
)
from qiskit.dagcircuit import DAGCircuit
from qiskit.transpiler.basepasses import TransformationPass
from qiskit.transpiler.passes.optimization import Optimize1qGatesDecomposition
from qdk_chemistry.data import Settings
from qdk_chemistry.definitions import DIAGONAL_Z_1Q_GATES
from qdk_chemistry.utils import Logger
__all__ = [
"MergeZBasisRotations",
"RemoveZBasisOnZeroState",
"SubstituteCliffordRz",
"SubstitutePauliRotation",
]
[docs]
class MergeZBasisRotations(TransformationPass):
r"""Transformation pass to merge consecutive Z-basis rotations into a single Rz gate and remove identity gates.
This pass identifies sequences of single-qubit gates in the Z-basis,
specifically Rz(θ), Z, S, and Sdg, and combines them into a single Rz(θ_new)
operation whenever possible. These gates all correspond to rotations around the
Z-axis of the Bloch sphere and can be represented in a unified form.
Gates:
* Rz(θ): Arbitrary rotation by angle θ.
* Z: Equivalent to Rz(π).
* S: Equivalent to Rz(π/2).
* Sdg: Equivalent to Rz(-π/2).
* Id: Equivalent to Rz(0) (no effect, removed).
Behavior:
* Does not merge across non-Z-basis gates (e.g., X, H, CX).
* Removes Id gates entirely since they have no effect.
* Respects circuit boundaries and barriers.
Example:
Input sequence:
:math:`S \rightarrow R_z(π/3) \rightarrow S^\dagger \rightarrow Z`
Output:
:math:`R_z(π/2 + π/3 - π/2 + π) = R_z(π + π/3)`
Note:
* Useful for simplifying circuits before basis gate decomposition.
* Reduces gate count and improves optimization opportunities downstream.
"""
[docs]
def __init__(self):
"""Use Optimize1qGatesDecomposition to handle gate optimization to merge Z basis rotations."""
Logger.trace_entering()
super().__init__()
self._optimize1q_decomposition = Optimize1qGatesDecomposition(basis=["rz", "rx"])
[docs]
def run(self, dag: DAGCircuit) -> DAGCircuit:
"""Run the pass on the given ``DAGCircuit``.
Args:
dag: The input ``DAGCircuit`` to transform.
Returns:
The transformed ``DAGCircuit`` with merged Z-basis rotations.
"""
Logger.trace_entering()
Logger.debug("Running MergeZBasisRotations pass.")
new_dag = DAGCircuit()
for qreg in dag.qregs.values():
new_dag.add_qreg(qreg)
for creg in dag.cregs.values():
new_dag.add_creg(creg)
current_region = DAGCircuit()
for qreg in dag.qregs.values():
current_region.add_qreg(qreg)
for creg in dag.cregs.values():
current_region.add_creg(creg)
for node in dag.topological_op_nodes():
name = node.op.name
is_z_basis_gate = name in {"rz", "z", "s", "sdg"}
is_id_gate = name == "id"
if is_id_gate:
# Remove Id gates (no effect on state)
continue
if is_z_basis_gate:
# Add Z-basis gate to current merge region
current_region.apply_operation_back(node.op, node.qargs, node.cargs)
else:
# Non-Z-basis gate: process current region first
if current_region.size() > 0:
optimized_region = self._optimize1q_decomposition.run(current_region)
new_dag.compose(optimized_region, inplace=True)
current_region = DAGCircuit()
for qreg in dag.qregs.values():
current_region.add_qreg(qreg)
for creg in dag.cregs.values():
current_region.add_creg(creg)
# Add this non-Z gate directly (acts as boundary)
new_dag.apply_operation_back(node.op, node.qargs, node.cargs)
# Process any remaining region
if current_region.size() > 0:
optimized_region = self._optimize1q_decomposition.run(current_region)
new_dag.compose(optimized_region, inplace=True)
return new_dag
class SubstituteCliffordRzSettings(Settings):
"""Settings configuration for SubstituteCliffordRz.
SubstituteCliffordRz-specific settings:
equivalent_gate_set (vector<string>, default=["id", "t", "s", "sdg", "tdg", "z"]): Equivalent gate set to use.
tolerance (double, default=float(np.finfo(np.float64).eps)): Float comparison tolerance to use.
"""
def __init__(self):
"""Initialize SubstituteCliffordRzSettings."""
Logger.trace_entering()
super().__init__()
self._set_default("equivalent_gate_set", "vector<string>", ["id", "t", "s", "z", "sdg", "tdg"])
self._set_default("tolerance", "double", float(np.finfo(np.float64).eps))
def set(self, key: str, value):
"""Override set to ensure 'id' is always in equivalent_gate_set and duplicates are removed.
Args:
key (str): Setting key to set.
value: Value to set.
"""
# Ensure 'id' is present in equivalent_gate_set and remove duplicates
if key == "equivalent_gate_set" and isinstance(value, list):
value = list({*value, "id"})
Logger.trace_entering()
super().set(key, value)
def update(self, settings_dict: dict):
"""Override update to ensure 'id' is always in equivalent_gate_set and duplicates are removed.
Args:
settings_dict (dict): Dictionary of settings to update.
"""
# Ensure 'id' is present in equivalent_gate_set and remove duplicates
if "equivalent_gate_set" in settings_dict and isinstance(settings_dict["equivalent_gate_set"], list):
settings_dict = {
**settings_dict,
"equivalent_gate_set": list({*settings_dict["equivalent_gate_set"], "id"}),
}
Logger.trace_entering()
super().update(settings_dict)
[docs]
class SubstituteCliffordRz(TransformationPass):
"""Transformation pass to substitute Rz(θ) gates with equivalent Clifford+T gates for special angles.
This pass replaces Rz(θ) gates with one of the following gates:
* Identity (Id)
* T gate (T)
* Phase gate (S)
* Pauli-Z (Z)
* Inverse Phase gate (Sdg)
* T-dagger gate (Tdg)
Substitution rules:
+--------------------+--------------------------+
| Rz angle (θ) | Equivalent gate |
+====================+==========================+
| 0 | Id |
+--------------------+--------------------------+
| π/4 | T |
+--------------------+--------------------------+
| π/2 | S |
+--------------------+--------------------------+
| π | Z |
+--------------------+--------------------------+
| -π/2 or 3π/2 | Sdg |
+--------------------+--------------------------+
| -π/4 or 7π/4 | Tdg |
+--------------------+--------------------------+
Note:
* Only substitutes gates whose angle is non-parameterized and matches
one of the above special phases within the specified tolerance.
* Leaves parameterized Rz gates untouched to preserve symbolic expressions.
* Ignores gates not in the user-specified ``equivalent_gate_set``
"""
[docs]
def __init__(
self,
equivalent_gate_set: list[str] | None = None,
tolerance: float = float(np.finfo(np.float64).eps),
):
"""Initialize the SubstituteCliffordRz transformation pass.
Args:
equivalent_gate_set (list[str] | None): List of gates to substitute rz with special
angles. Default is None, which means ['id', 't', 's', 'z', 'sdg', 'tdg'].
tolerance (float): Angle comparison tolerance. Default is np.finfo(np.float64).eps.
"""
Logger.trace_entering()
super().__init__()
self._settings = SubstituteCliffordRzSettings()
if equivalent_gate_set is not None:
if not isinstance(equivalent_gate_set, list):
raise TypeError("equivalent_gate_set must be a list of gate names or None")
self._settings.set("equivalent_gate_set", equivalent_gate_set)
self._settings.set("tolerance", tolerance)
self._factor_to_gate: tuple[tuple[int, str, type], ...] = (
(0, "id", IGate),
(1, "t", TGate),
(2, "s", SGate),
(4, "z", ZGate),
(6, "sdg", SdgGate),
(7, "tdg", TdgGate),
)
self._build_lookup()
def _build_lookup(self):
"""Precompute the mod-8 lookup table from current settings."""
gate_set = set(self._settings.get("equivalent_gate_set"))
tolerance = self._settings.get("tolerance")
# Precompute (mod8_target, gate_instance) pairs for enabled gates
self._lookup: list[tuple[float, object]] = []
for mod8_val, name, cls in self._factor_to_gate:
if name in gate_set:
self._lookup.append((float(mod8_val), cls()))
self._tolerance = tolerance
self._inv_quarter_pi = 4.0 / np.pi # precompute reciprocal
[docs]
def run(self, dag: DAGCircuit) -> DAGCircuit:
"""Run the pass on the given ``DAGCircuit``.
Args:
dag: The input ``DAGCircuit`` to transform.
Returns:
The transformed ``DAGCircuit`` with Rz substitutions.
"""
Logger.trace_entering()
tolerance = self._tolerance
lookup = self._lookup
inv_quarter_pi = self._inv_quarter_pi
for node in dag.op_nodes():
if node.op.name != "rz":
continue
angle = node.op.params[0]
# Skip parameterized rotations
if isinstance(angle, ParameterExpression):
continue
# Compute mod-8 factor: angle = factor * (π/4)
factor = angle * inv_quarter_pi
mod8 = factor % 8.0
for target, gate in lookup:
if abs(mod8 - target) <= tolerance or abs(mod8 - target - 8.0) <= tolerance:
dag.substitute_node(node, gate, inplace=True)
break
return dag
[docs]
def settings(self) -> Settings:
"""Get the settings for SubstituteCliffordRz.
Returns:
The settings object associated with SubstituteCliffordRz.
"""
Logger.trace_entering()
return self._settings
class SubstitutePauliRotationSettings(Settings):
"""Settings configuration for SubstitutePauliRotation.
SubstitutePauliRotation-specific settings:
equivalent_gate_set (vector<string>, default=["id", "x", "y", "z"]): Equivalent gate set to use.
tolerance (double, default=float(np.finfo(np.float64).eps)): Float comparison tolerance to use.
"""
def __init__(self):
"""Initialize SubstitutePauliRotationSettings."""
Logger.trace_entering()
super().__init__()
self._set_default("equivalent_gate_set", "vector<string>", ["id", "x", "y", "z"])
self._set_default("tolerance", "double", float(np.finfo(np.float64).eps))
def set(self, key: str, value):
"""Override set to ensure 'id' is always in equivalent_gate_set and duplicates are removed.
Args:
key (str): Setting key to set.
value: Value to set.
"""
if key == "equivalent_gate_set" and isinstance(value, list):
value = list({*value, "id"})
Logger.trace_entering()
super().set(key, value)
def update(self, settings_dict: dict):
"""Override update to ensure 'id' is always in equivalent_gate_set and duplicates are removed.
Args:
settings_dict (dict): Dictionary of settings to update.
"""
if "equivalent_gate_set" in settings_dict and isinstance(settings_dict["equivalent_gate_set"], list):
settings_dict = {
**settings_dict,
"equivalent_gate_set": list({*settings_dict["equivalent_gate_set"], "id"}),
}
Logger.trace_entering()
super().update(settings_dict)
[docs]
class SubstitutePauliRotation(TransformationPass):
r"""Substitute Pauli rotation gates at integer multiples of π.
Handles both 1-qubit (Rx, Ry, Rz) and 2-qubit (Rxx, Ryy, Rzz) rotations:
* :math:`R_P(k\pi)` where *k* is even → removed (identity up to global phase).
* :math:`R_P(k\pi)` where *k* is odd → the corresponding Pauli gate(s).
For 2-qubit rotations the Pauli is applied independently to each qubit.
"""
[docs]
def __init__(
self,
equivalent_gate_set: list[str] | None = None,
tolerance: float = float(np.finfo(np.float64).eps),
):
"""Initialize SubstitutePauliRotation.
Args:
equivalent_gate_set: List of gates to allow substitution with, or None for
defaults (``["id", "x", "y", "z"]``).
tolerance: Angle comparison tolerance.
"""
Logger.trace_entering()
super().__init__()
self._rotation_gates = {
"rx": (XGate, RXGate),
"ry": (YGate, RYGate),
"rz": (ZGate, RZGate),
"rxx": (XGate, RXXGate),
"ryy": (YGate, RYYGate),
"rzz": (ZGate, RZZGate),
}
self._settings = SubstitutePauliRotationSettings()
if equivalent_gate_set is not None:
if not isinstance(equivalent_gate_set, list):
raise TypeError("equivalent_gate_set must be a list of gate names or None")
self._settings.set("equivalent_gate_set", equivalent_gate_set)
self._settings.set("tolerance", tolerance)
[docs]
def substitute_pauli_rotations(
self,
dag: DAGCircuit,
rotation_gates: dict[str, tuple],
equivalent_gate_set: list[str],
tolerance: float,
) -> DAGCircuit:
r"""Replace rotation gates at integer multiples of π with Pauli gates or identity.
For a rotation gate :math:`R_P(\theta)`:
* Even multiples of π (0, 2π, …) → identity (gate removed).
* Odd multiples of π (π, 3π, …) → the corresponding Pauli gate(s).
For 2-qubit rotations the Pauli is applied independently to each qubit.
Args:
dag: The input ``DAGCircuit`` to transform.
rotation_gates: Gate-name → (Pauli class, Rotation class) mapping.
equivalent_gate_set: List of gate names allowed for substitution.
tolerance: Angle comparison tolerance.
Returns:
The transformed ``DAGCircuit`` with Pauli substitutions.
"""
if "id" not in equivalent_gate_set:
raise ValueError("Gate 'id' is missing in equivalent_gate_set.")
if len(equivalent_gate_set) != len(set(equivalent_gate_set)):
raise ValueError(f"Gates in equivalent_gate_set ({equivalent_gate_set}) are not unique.")
for node in dag.op_nodes():
if node.op.name not in rotation_gates:
continue
angle = node.op.params[0]
if isinstance(angle, ParameterExpression):
continue
pauli_cls, _ = rotation_gates[node.op.name]
pauli_name = pauli_cls().name # e.g. "x", "y", "z"
k = round(float(angle) / np.pi)
if abs(float(angle) - k * np.pi) > tolerance:
continue
if k % 2 == 0 and "id" in equivalent_gate_set:
# Even multiple of π → identity; remove the gate
dag.remove_op_node(node)
elif k % 2 != 0 and pauli_name in equivalent_gate_set:
# Odd multiple of π → Pauli gate(s)
if len(node.qargs) == 1:
dag.substitute_node(node, pauli_cls(), inplace=True)
else:
replacement = DAGCircuit()
replacement.add_qubits(node.qargs)
for qubit in node.qargs:
replacement.apply_operation_back(pauli_cls(), qargs=[qubit])
dag.substitute_node_with_dag(node, replacement)
return dag
[docs]
def run(self, dag: DAGCircuit) -> DAGCircuit:
"""Run the pass on the given ``DAGCircuit``.
Args:
dag: The input ``DAGCircuit`` to transform.
Returns:
The transformed ``DAGCircuit`` with Pauli substitutions.
"""
Logger.trace_entering()
Logger.debug("Running SubstitutePauliRotation pass.")
return self.substitute_pauli_rotations(
dag,
self._rotation_gates,
self._settings.get("equivalent_gate_set"),
self._settings.get("tolerance"),
)
[docs]
def settings(self) -> Settings:
"""Get the settings for this pass.
Returns:
The settings object associated with this pass.
"""
Logger.trace_entering()
return self._settings
[docs]
class RemoveZBasisOnZeroState(TransformationPass):
r"""Transformation pass to remove Z-basis operations on qubits that are in the :math:`\lvert 0 \rangle` state.
This optimization eliminates gates that apply only a global phase to the qubit,
which has no effect on observable outcomes (measurement probabilities) or
downstream quantum operations. Specifically, diagonal gates in the computational
basis (e.g., Rz(θ), Z, S, Sdg) act trivially on the :math:`\lvert 0 \rangle` state:
* :math:`R_z(θ) \lvert 0 \rangle = e^{-iθ/2} \lvert 0 \rangle`
* :math:`Z \lvert 0 \rangle = +1 \lvert 0 \rangle`
* :math:`S \lvert 0 \rangle = +1 \lvert 0 \rangle`
* :math:`S^\dagger \lvert 0 \rangle = +1 \lvert 0 \rangle`
These gates only introduce a global phase factor, which is physically unobservable.
This transformation must not be applied to qubits in superposition or entangled
states, since Z-basis rotations there modify relative phases between basis states.
"""
[docs]
def __init__(self):
"""Initialize the ``RemoveZBasisOnZeroState`` transformation pass."""
Logger.trace_entering()
super().__init__()
self._z_basis_gates = {"rz", "z", "s", "sdg"}
[docs]
def run(self, dag: DAGCircuit) -> DAGCircuit:
"""Run the pass on the given ``DAGCircuit``.
Args:
dag: The input ``DAGCircuit`` to transform.
Returns:
The transformed ``DAGCircuit`` with Z-basis gates removed.
"""
Logger.trace_entering()
Logger.debug("Running RemoveZBasisOnZeroState pass.")
# Track qubits still in |0⟩ (True means untouched)
zero_state_qubits = dict.fromkeys(dag.qubits, True)
nodes_to_process = list(dag.topological_op_nodes())
for node in nodes_to_process:
name = node.op.name
qubits = node.qargs
# Check if Z-basis gate and qubit still in |0⟩
if name in self._z_basis_gates:
remove_gate = all(zero_state_qubits.get(q, False) for q in qubits)
if remove_gate:
Logger.debug(f"Removing {name} on qubit {qubits} (still |0⟩)")
dag.remove_op_node(node)
continue # Skip to next node
# Mark qubits as no longer |0⟩ for non-diagonal gates
if name not in self._z_basis_gates and not self._is_diagonal(name):
for q in qubits:
zero_state_qubits[q] = False
return dag
def _is_diagonal(self, gate_name: str) -> bool:
"""Determine if a gate is diagonal in computational basis.
Args:
gate_name: Name of the gate.
Returns:
bool: True if the gate is diagonal, False otherwise.
Note:
The gate classification logic depends on the ``DIAGONAL_Z_1Q_GATES`` defined in ``definitions.py``.
"""
Logger.trace_entering()
return gate_name in DIAGONAL_Z_1Q_GATES