# SPDX-License-Identifier: Apache-2.0
# (C) Copyright IBM 2025.
"""Utility functions for Bowtie Quantum Geometric Tensor computations.
This module provides helper functions for computing parameter and observable
bowtie circuits, which are essential for quantum geometric tensor calculations.
"""
from qiskit import QuantumCircuit
from qiskit.circuit import ControlFlowOp, Gate, Parameter, ParameterExpression
from qiskit.circuit.commutation_library import SessionCommutationChecker as scc
from qiskit.circuit.library import PauliGate, XGate, YGate, ZGate
from qiskit.converters import circuit_to_dag, dag_to_circuit
from qiskit.dagcircuit import DAGCircuit
from qiskit.transpiler import PassManager
from qiskit.transpiler.passes.optimization.light_cone import LightCone
# Mapping of rotation gate names to their corresponding Pauli gate objects
_rotation_gates: dict[str, Gate] = {
"rx": XGate(),
"ry": YGate(),
"rz": ZGate(),
"rzz": PauliGate("ZZ"),
"rxx": PauliGate("XX"),
"ryy": PauliGate("YY"),
}
[docs]
def validate_circuit(qc: QuantumCircuit) -> None:
"""Check that the circuit only uses parameters the bowtie method supports.
Every parameter must appear exactly once, as the bare angle of a supported
Pauli rotation gate.
Args:
qc: The parameterized quantum circuit.
Raises:
ValueError: If a gate takes a parameter expression (e.g. ``2*θ``), if a
parameterized gate is not a supported Pauli rotation, if a parameter
appears in more than one gate, or if the circuit has classical bits,
control flow or a parameterized global phase.
"""
if qc.num_clbits:
raise ValueError(
"Circuits with classical bits are not supported. Remove measurements and "
"classical registers (e.g. qc.remove_final_measurements())."
)
if isinstance(qc.global_phase, ParameterExpression):
raise ValueError("A parameterized global phase is not supported.")
supported = ", ".join(_rotation_gates)
seen = set()
for instruction in qc.data:
op = instruction.operation
if isinstance(op, ControlFlowOp):
raise ValueError(
f"Control-flow operation '{op.name}' is not supported. Flatten the circuit "
"into plain gates first."
)
for param in op.params:
if not isinstance(param, ParameterExpression):
continue
if not isinstance(param, Parameter):
raise ValueError(
f"Gate '{op.name}' takes the parameter expression {param}. Parameter "
"expressions are not supported: each gate angle must be a single "
"Parameter. Use a fresh Parameter and map values to it yourself."
)
if op.name not in _rotation_gates:
raise ValueError(
f"Parameterized gate '{op.name}' is not supported. Supported "
f"parameterized gates are: {supported}. Transpile the circuit to "
"this basis first."
)
if param in seen:
raise ValueError(
f"Parameter {param} appears in more than one gate. Each parameter "
"must appear in exactly one gate; use a separate Parameter per gate."
)
seen.add(param)
[docs]
def _extend_lightcone(
dag: DAGCircuit, light_cone: set[int], light_cone_ops: list[tuple[Gate, tuple]]
) -> DAGCircuit:
"""Extend the light cone backwards through the circuit.
This method takes an initial light cone at the end of the DAGCircuit
and extends it backwards by identifying operations that do not commute
with the light cone operations. Operations that commute are removed.
Args:
dag: The directed acyclic graph representation of the quantum circuit.
light_cone: Set of qubit indices currently in the light cone.
light_cone_ops: List of tuples containing (operation, qubits) that
define the light cone operations.
Returns:
The modified DAGCircuit with operations outside the light cone removed.
Note:
This function modifies the input DAG in place and also returns it.
"""
# iterate over the nodes in reverse topological order
for node in reversed(list(dag.topological_op_nodes())):
if not light_cone.intersection(node.qargs):
dag.remove_op_node(node)
continue
commutes_bool = True
for op in light_cone_ops:
commute_bool = scc.commute(op[0], op[1], [], node.op, node.qargs, [])
if not commute_bool:
light_cone.update(node.qargs)
light_cone_ops.append((node.op, node.qargs))
commutes_bool = False
break
if commutes_bool:
dag.remove_op_node(node)
return dag
[docs]
def parameter_bowtie(dag: DAGCircuit, target_parameter: Parameter) -> QuantumCircuit:
"""Compute the auxiliary bowtie circuit for a given parameter.
The bowtie circuit is constructed by:
1. Finding the gate containing the target parameter
2. Computing the light cone of that gate
3. Sandwiching the generator of the rotation gate between the light cone
circuit and its inverse
Args:
dag: The directed acyclic graph representation of the quantum circuit.
target_parameter: The parameter for which to compute the bowtie circuit.
Returns:
The full parameter bowtie circuit, which may contain some idle quantum
registers that can be removed using `remove_idle_qubits`.
Raises:
ValueError: If the target parameter is not found in any gate in the circuit.
Example:
>>> from qiskit import QuantumCircuit
>>> from qiskit.circuit import Parameter
>>> from qiskit.converters import circuit_to_dag
>>> theta = Parameter('θ')
>>> qc = QuantumCircuit(2)
>>> qc.rx(theta, 0)
>>> dag = circuit_to_dag(qc)
>>> bowtie = parameter_bowtie(dag, theta)
"""
for node in reversed(list(dag.topological_op_nodes())):
if getattr(node.op, "params", None) == [target_parameter]:
light_cone = {q for q in node.qargs}
light_cone_ops = [(node.op, node.qargs)]
qc = dag_to_circuit(_extend_lightcone(dag, light_cone, light_cone_ops))
full_circuit = qc.copy()
derivative = _rotation_gates[node.name]
full_circuit.compose(derivative, node.qargs, inplace=True)
full_circuit.compose(qc.inverse(), inplace=True)
return full_circuit
else:
dag.remove_op_node(node)
raise ValueError(f"Did not find parameter {target_parameter}")
[docs]
def observable_bowtie(qc: QuantumCircuit, bit_terms: str, indices: list[int]) -> QuantumCircuit:
"""Compute the auxiliary bowtie circuit for a given observable.
The bowtie circuit is constructed by:
1. Computing the light cone of the observable
2. Sandwiching the Pauli gate corresponding to the observable between
the light cone circuit and its inverse
Args:
qc: The quantum circuit for which to compute the observable bowtie.
bit_terms: Pauli string representing the observable terms (e.g., "XYZ"
for X⊗Y⊗Z). The string is reversed internally to match Qiskit's
qubit ordering convention.
indices: List of qubit indices on which the Pauli terms act, ordered
to correspond with the bit_terms string.
Returns:
The full observable bowtie circuit, which may contain some idle quantum
registers that can be removed using `remove_idle_qubits`.
Example:
>>> from qiskit import QuantumCircuit
>>> qc = QuantumCircuit(3)
>>> qc.h(0)
>>> qc.cx(0, 1)
>>> bowtie = observable_bowtie(qc, "ZZ", [0, 1])
Note:
TODO: Use DAG representation instead of circuit representation for
consistency with parameter_bowtie.
"""
light_cone_circuit = PassManager(LightCone(bit_terms, indices)).run([qc])[0]
bowtie_circuit = light_cone_circuit.copy()
bowtie_circuit.compose(PauliGate(bit_terms[::-1]), qubits=indices, inplace=True)
bowtie_circuit.compose(light_cone_circuit.inverse(), inplace=True)
return bowtie_circuit
[docs]
def remove_idle_qubits(qc: QuantumCircuit) -> tuple[QuantumCircuit, tuple[int, ...]]:
"""Remove idle quantum wires from a circuit.
This function identifies and removes quantum wires (qubits) that have no
operations applied to them throughout the circuit, returning a reduced
circuit and the indices of the active wires.
Args:
qc: The quantum circuit from which to remove idle wires.
Returns:
A tuple containing:
- The reduced quantum circuit with idle wires removed
- A tuple of indices indicating which qubits from the original circuit
remain in the reduced circuit (in order)
Example:
>>> from qiskit import QuantumCircuit
>>> qc = QuantumCircuit(5)
>>> qc.h(0)
>>> qc.cx(0, 2)
>>> reduced_qc, active_indices = remove_idle_qubits(qc)
>>> print(f"Active qubits: {active_indices}")
Active qubits: (0, 2)
>>> print(f"Reduced circuit has {reduced_qc.num_qubits} qubits")
Reduced circuit has 2 qubits
"""
dag = circuit_to_dag(qc)
idle_wires = list(dag.idle_wires())
idle_wires_indices = [qc.find_bit(q).index for q in idle_wires]
active_wires_indices = tuple(w for w in range(qc.num_qubits) if w not in idle_wires_indices)
dag.remove_qubits(*idle_wires)
circuit = dag_to_circuit(dag)
return circuit, active_wires_indices