# 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.
"""
Definition of the base Qasm module
"""
from abc import ABC, abstractmethod
from copy import deepcopy
from typing import Optional
import openqasm3.ast as qasm3_ast
from openqasm3.ast import BranchingStatement, Program, QuantumGate
from pyqasm.analyzer import Qasm3Analyzer
from pyqasm.decomposer import Decomposer
from pyqasm.elements import ClbitDepthNode, QubitDepthNode
from pyqasm.exceptions import UnrollError, ValidationError
from pyqasm.maps import QUANTUM_STATEMENTS
from pyqasm.maps.decomposition_rules import DECOMPOSITION_RULES
from pyqasm.visitor import QasmVisitor
[docs]
class QasmModule(ABC): # pylint: disable=too-many-instance-attributes
"""Abstract class for a Qasm module
Args:
name (str): Name of the module.
program (Program): The original openqasm3 program.
statements (list[Statement]): list of openqasm3 Statements.
"""
[docs]
def __init__(self, name: str, program: Program):
self._name = name
self._original_program = program
self._statements = program.statements
self._num_qubits = -1
self._num_clbits = -1
self._qubit_depths: dict[tuple[str, int], QubitDepthNode] = {}
self._clbit_depths: dict[tuple[str, int], ClbitDepthNode] = {}
self._qubit_registers: dict[str, int] = {}
self._classical_registers: dict[str, int] = {}
self._has_measurements: Optional[bool] = None
self._has_barriers: Optional[bool] = None
self._validated_program = False
self._unrolled_ast = Program(statements=[])
@property
def name(self) -> str:
"""Returns the name of the module."""
return self._name
@property
def num_qubits(self) -> int:
"""Returns the number of qubits in the circuit."""
if self._num_qubits == -1:
self._num_qubits = 0
self.validate()
return self._num_qubits
@num_qubits.setter
def num_qubits(self, value: int):
"""Setter for the number of qubits"""
self._num_qubits = value
def _add_qubit_register(self, reg_name: str, num_qubits: int):
"""Add qubits to the module
Args:
num_qubits (int): The number of qubits to add to the module
Returns:
None
"""
self._qubit_registers[reg_name] = num_qubits
self._num_qubits += num_qubits
@property
def num_clbits(self) -> int:
"""Returns the number of classical bits in the circuit."""
if self._num_clbits == -1:
self._num_clbits = 0
self.validate()
return self._num_clbits
@num_clbits.setter
def num_clbits(self, value: int):
"""Setter for the number of classical bits"""
self._num_clbits = value
def _add_classical_register(self, reg_name: str, num_clbits: int):
"""Add classical bits to the module
Args:
num_clbits (int): The number of classical bits to add to the module
Returns:
None
"""
self._classical_registers[reg_name] = num_clbits
self._num_clbits += num_clbits
@property
def original_program(self) -> Program:
"""Returns the program AST for the original qasm supplied by the user"""
return self._original_program
@property
def unrolled_ast(self) -> Program:
"""Returns the unrolled AST for the module"""
return self._unrolled_ast
@unrolled_ast.setter
def unrolled_ast(self, value: Program):
"""Setter for the unrolled AST"""
self._unrolled_ast = value
def has_measurements(self) -> bool:
"""Check if the module has any measurement operations."""
if self._has_measurements is None:
self._has_measurements = False
# try to check in the unrolled version as that will a better indicator of
# the presence of measurements
stmts_to_check = (
self._unrolled_ast.statements
if len(self._unrolled_ast.statements) > 0
else self._statements
)
for stmt in stmts_to_check:
if isinstance(stmt, qasm3_ast.QuantumMeasurementStatement):
self._has_measurements = True
break
return self._has_measurements
def remove_measurements(self, in_place: bool = True) -> Optional["QasmModule"]:
"""Remove the measurement operations
Args:
in_place (bool): Flag to indicate if the removal should be done in place.
Returns:
QasmModule: The module with the measurements removed if in_place is False
"""
stmt_list = (
self._statements
if len(self._unrolled_ast.statements) == 0
else self._unrolled_ast.statements
)
stmts_without_meas = [
stmt
for stmt in stmt_list
if not isinstance(stmt, qasm3_ast.QuantumMeasurementStatement)
]
curr_module = self
if not in_place:
curr_module = self.copy()
for qubit in curr_module._qubit_depths.values():
qubit.num_measurements = 0
for clbit in curr_module._clbit_depths.values():
clbit.num_measurements = 0
curr_module._has_measurements = False
curr_module._statements = stmts_without_meas
curr_module._unrolled_ast.statements = stmts_without_meas
return curr_module
def has_barriers(self) -> bool:
"""Check if the module has any barrier operations.
Args:
None
Returns:
bool: True if the module has barrier operations, False otherwise
"""
if self._has_barriers is None:
self._has_barriers = False
# try to check in the unrolled version as that will a better indicator of
# the presence of barriers
stmts_to_check = (
self._unrolled_ast.statements
if len(self._unrolled_ast.statements) > 0
else self._statements
)
for stmt in stmts_to_check:
if isinstance(stmt, qasm3_ast.QuantumBarrier):
self._has_barriers = True
break
return self._has_barriers
def remove_barriers(self, in_place: bool = True) -> Optional["QasmModule"]:
"""Remove the barrier operations
Args:
in_place (bool): Flag to indicate if the removal should be done in place.
Returns:
QasmModule: The module with the barriers removed if in_place is False
"""
stmt_list = (
self._statements
if len(self._unrolled_ast.statements) == 0
else self._unrolled_ast.statements
)
stmts_without_barriers = [
stmt for stmt in stmt_list if not isinstance(stmt, qasm3_ast.QuantumBarrier)
]
curr_module = self
if not in_place:
curr_module = self.copy()
for qubit in curr_module._qubit_depths.values():
qubit.num_barriers = 0
curr_module._has_barriers = False
curr_module._statements = stmts_without_barriers
curr_module._unrolled_ast.statements = stmts_without_barriers
return curr_module
def remove_includes(self, in_place=True) -> Optional["QasmModule"]:
"""Remove the include statements from the module
Args:
in_place (bool): Flag to indicate if the removal should be done in place.
Returns:
QasmModule: The module with the includes removed if in_place is False, None otherwise
"""
stmt_list = (
self._statements
if len(self._unrolled_ast.statements) == 0
else self._unrolled_ast.statements
)
stmts_without_includes = [
stmt for stmt in stmt_list if not isinstance(stmt, qasm3_ast.Include)
]
curr_module = self
if not in_place:
curr_module = self.copy()
curr_module._statements = stmts_without_includes
curr_module._unrolled_ast.statements = stmts_without_includes
return curr_module
def depth(self):
"""Calculate the depth of the unrolled openqasm program.
Args:
None
Returns:
int: The depth of the current "unrolled" openqasm program
"""
# 1. Since the program will be unrolled before its execution on a QC, it makes sense to
# calculate the depth of the unrolled program.
# We are performing operations in place, thus we need to calculate depth
# at "each instance of the function call".
# TODO: optimize by tracking whether the program changed since we
# last calculated the depth
qasm_module = self.copy()
qasm_module._qubit_depths = {}
qasm_module._clbit_depths = {}
qasm_module.unroll()
max_depth = 0
max_qubit_depth, max_clbit_depth = 0, 0
# calculate the depth using the qubit and clbit depths
if len(qasm_module._qubit_depths) != 0:
max_qubit_depth = max(qubit.depth for qubit in qasm_module._qubit_depths.values())
if len(qasm_module._clbit_depths) != 0:
max_clbit_depth = max(clbit.depth for clbit in qasm_module._clbit_depths.values())
max_depth = max(max_qubit_depth, max_clbit_depth)
return max_depth
def _remap_qubits(self, reg_name: str, size: int, idle_indices: list[int]):
"""Remap the qubits in a register after removing idle qubits and update the operations
using this register accordingly"""
used_indices = [idx for idx in range(size) if idx not in idle_indices]
new_size = size - len(idle_indices)
idx_map = {used_indices[i]: i for i in range(new_size)} # old_idx : new_idx
# Example -
# reg_name = "q", original_size = 5, idle_indices = [1, 3]
# used_indices = [0, 2, 4], new_size = 3
# idx_map = {0: 0, 2: 1, 4: 2}
# update the qubit register size
self._qubit_registers[reg_name] = new_size
# update the qubit declaration in the unrolled ast
for stmt in self._unrolled_ast.statements:
if isinstance(stmt, qasm3_ast.QubitDeclaration):
if stmt.qubit.name == reg_name:
stmt.size.value = new_size # type: ignore[union-attr]
break
# update the qubit depths
for idx in used_indices:
qubit = self._qubit_depths[(reg_name, idx)]
qubit.reg_index = idx_map[idx]
self._qubit_depths[(reg_name, idx_map[idx])] = deepcopy(qubit)
del self._qubit_depths[(reg_name, idx)]
# update the operations that use the qubits
for operation in self._unrolled_ast.statements:
if isinstance(operation, QUANTUM_STATEMENTS):
bit_list = Qasm3Analyzer.get_op_bit_list(operation)
for bit in bit_list:
assert isinstance(bit, qasm3_ast.IndexedIdentifier)
if bit.name.name == reg_name:
old_idx = bit.indices[0][0].value # type: ignore[union-attr,index]
bit.indices[0][0].value = idx_map[old_idx] # type: ignore[union-attr,index]
def _get_idle_qubit_indices(self) -> dict[str, list[int]]:
"""Get the indices of the idle qubits in the module
Returns:
dict[str, list[int]]: A dictionary mapping the register name to the list of idle qubit
indices in that register
"""
idle_qubits = [qubit for qubit in self._qubit_depths.values() if qubit.is_idle()]
# re-map the idle qubits as {reg_name: [indices]}
qubit_indices: dict[str, list[int]] = {}
for qubit in idle_qubits:
if qubit.reg_name not in qubit_indices:
qubit_indices[qubit.reg_name] = []
qubit_indices[qubit.reg_name].append(qubit.reg_index)
return qubit_indices
def populate_idle_qubits(self, in_place: bool = True):
"""Populate the idle qubits in the module with identity gates
Note: unrolling is not performed while calling this function
Args:
in_place (bool): Flag to indicate if the population should be done in place.
Returns:
QasmModule: The module with the idle qubits populated. If in_place is False, a new
module with the populated idle qubits is returned.
"""
qasm_module = self if in_place else self.copy()
qasm_module.validate()
idle_qubit_indices = qasm_module._get_idle_qubit_indices()
id_gate_list = []
for reg_name, idle_indices in idle_qubit_indices.items():
for idx in idle_indices:
# increment the depth of the idle qubits by 1
qasm_module._qubit_depths[(reg_name, idx)].depth += 1
# add an identity gate to the qubits that are idle
id_gate = qasm3_ast.QuantumGate(
modifiers=[],
name=qasm3_ast.Identifier(name="id"),
arguments=[],
qubits=[
qasm3_ast.IndexedIdentifier(
name=qasm3_ast.Identifier(name=reg_name),
indices=[[qasm3_ast.IntegerLiteral(value=idx)]],
)
],
)
id_gate_list.append(id_gate)
qasm_module.original_program.statements.extend(id_gate_list)
qasm_module._statements = qasm_module.original_program.statements
return qasm_module
def remove_idle_qubits(self, in_place: bool = True):
"""Remove idle qubits from the module. Either collapse the size of a partially used
quantum register OR remove the unused quantum register entirely.
Will unroll the module if not already done.
Args:
in_place (bool): Flag to indicate if the removal should be done in place.
Returns:
QasmModule: The module the idle qubits removed. If in_place is False, a new module
with the reversed qubit order is returned.
"""
qasm_module = self if in_place else self.copy()
qasm_module.unroll()
idle_qubit_indices = qasm_module._get_idle_qubit_indices()
for reg_name, idle_indices in idle_qubit_indices.items():
# we have removed the idle qubits, so we can remove them from depth map
for idle_idx in idle_indices:
del qasm_module._qubit_depths[(reg_name, idle_idx)]
size = self._qubit_registers[reg_name]
if len(idle_indices) == size: # all qubits are idle
# remove the declaration from the unrolled ast
for stmt in qasm_module._unrolled_ast.statements:
if isinstance(stmt, qasm3_ast.QubitDeclaration):
if stmt.qubit.name == reg_name:
qasm_module._unrolled_ast.statements.remove(stmt)
qasm_module._statements.remove(stmt)
break
del qasm_module._qubit_registers[reg_name]
# we do not need to change any other operation as there will be no qubit usage
# if the complete register was unused
elif len(idle_indices) != 0: # partially used register
qasm_module._remap_qubits(reg_name, size, idle_indices)
# update the number of qubits
self._num_qubits -= len(idle_indices)
# the original ast will need to be updated to the unrolled ast as if we call the
# unroll operation again, it will incorrectly choose the original ast WITH THE IDLE QUBITS
qasm_module._statements = qasm_module._unrolled_ast.statements
return qasm_module
def reverse_qubit_order(self, in_place=True):
"""Reverse the order of qubits in the module.
Will unroll the module if not already done.
Args:
in_place (bool): Flag to indicate if the reversal should be done in place.
Returns:
QasmModule: The module the qubit order reversed. If in_place is False, a new module
with the reversed qubit order is returned.
"""
qasm_module = self if in_place else self.copy()
qasm_module.unroll()
new_qubit_mappings = {}
for register, size in self._qubit_registers.items():
new_qubit_mappings[register] = {0: 0}
if size > 1:
new_qubit_mappings[register] = {old_id: size - old_id - 1 for old_id in range(size)}
# Example -
# q[0], q[1], q[2], q[3] -> q[3], q[2], q[1], q[0]
# new_qubit_mappings = {"q": {0: 3, 1: 2, 2: 1, 3: 0}}
# 1. Qubit depths will be recalculated whenever we calculate the depth so we do not update
# the depth maps here
# 2. replace each qubit index in the Quantum Operations with the new index
for operation in qasm_module._unrolled_ast.statements:
if isinstance(operation, QUANTUM_STATEMENTS):
bit_list = Qasm3Analyzer.get_op_bit_list(operation)
for bit in bit_list:
curr_reg_name = bit.name.name
curr_reg_idx = bit.indices[0][0].value
new_reg_idx = new_qubit_mappings[curr_reg_name][curr_reg_idx]
# make the idx -ve so that this is not touched
# while updating the same index later
# idx -> -1 * idx - 1 as we also have to look at index 0
# which will remain 0 if we just multiply by -1
bit.indices[0][0].value = -1 * new_reg_idx - 1
# remove the -ve marker
for operation in qasm_module._unrolled_ast.statements:
if isinstance(operation, QUANTUM_STATEMENTS):
bit_list = Qasm3Analyzer.get_op_bit_list(operation)
for bit in bit_list:
if bit.indices[0][0].value < 0:
bit.indices[0][0].value += 1
bit.indices[0][0].value *= -1
# 3. update the original AST with the unrolled AST
qasm_module._statements = qasm_module._unrolled_ast.statements
# 4. return the module
return qasm_module
def validate(self):
"""Validate the module"""
if self._validated_program is True:
return
try:
self.num_qubits, self.num_clbits = 0, 0
visitor = QasmVisitor(self, check_only=True)
self.accept(visitor)
except (ValidationError, NotImplementedError) as err:
self.num_qubits, self.num_clbits = -1, -1
raise err
self._validated_program = True
def unroll(self, **kwargs):
"""Unroll the module into basic qasm operations.
Args:
**kwargs: Additional arguments to pass to the QasmVisitor.
external_gates (list[str]): List of gates that should not be unrolled.
unroll_barriers (bool): If True, barriers will be unrolled. Defaults to True.
check_only (bool): If True, only check the program without executing it.
Defaults to False.
Raises:
ValidationError: If the module fails validation during unrolling.
UnrollError: If an error occurs during the unrolling process.
Notes:
This method resets the module's qubit and classical bit counts before unrolling,
and sets them to -1 if an error occurs during unrolling.
"""
if not kwargs:
kwargs = {}
try:
self.num_qubits, self.num_clbits = 0, 0
visitor = QasmVisitor(module=self, **kwargs)
self.accept(visitor)
except (ValidationError, UnrollError) as err:
# reset the unrolled ast and qasm
self.num_qubits, self.num_clbits = -1, -1
self._unrolled_ast = Program(statements=[], version=self.original_program.version)
raise err
def rebase(self, target_basis_set, in_place=True):
"""Rebase the AST to use a specified target basis set.
Will unroll the module if not already done.
Args:
target_basis_set: The target basis set to rebase the module to.
in_place (bool): Flag to indicate if the rebase operation should be done in place.
Returns:
QasmModule: The module with the gates rebased to the target basis set.
"""
if target_basis_set not in DECOMPOSITION_RULES:
raise ValueError(f"Target basis set '{target_basis_set}' is not defined.")
qasm_module = self if in_place else self.copy()
if len(qasm_module._unrolled_ast.statements) == 0:
qasm_module.unroll()
rebased_statements = []
for statement in qasm_module._unrolled_ast.statements:
if isinstance(statement, QuantumGate):
gate_name = statement.name.name
# Decompose the gate
processed_gates_list = Decomposer.process_gate_statement(
gate_name, statement, target_basis_set
)
for processed_gate in processed_gates_list:
rebased_statements.append(processed_gate)
elif isinstance(statement, BranchingStatement):
# Recursively process the if_block and else_block
rebased_statements.append(
Decomposer.process_branching_statement(statement, target_basis_set)
)
else:
# Non-gate statements are directly appended
rebased_statements.append(statement)
# Replace the unrolled AST with the rebased one
qasm_module._unrolled_ast.statements = rebased_statements
return qasm_module
def __str__(self) -> str:
"""Return the string representation of the QASM program
Returns:
str: The string representation of the module
"""
if len(self._unrolled_ast.statements) > 1:
return self._qasm_ast_to_str(self.unrolled_ast)
return self._qasm_ast_to_str(self.original_program)
def copy(self):
"""Return a deep copy of the module"""
return deepcopy(self)
@abstractmethod
def _qasm_ast_to_str(self, qasm_ast):
"""Convert the qasm AST to a string"""
@abstractmethod
def accept(self, visitor):
"""Accept a visitor for the mßodule
Args:
visitor (QasmVisitor): The visitor to accept
"""