Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Types of changes:
### Removed

### Fixed
- Fixed `unroll()` and `rebase()` emitting statements that share operand AST nodes: gate decompositions passed the same `IndexedIdentifier` objects into every statement they emitted, so transformations that rewrite qubit indices in place mutated a shared node once per referencing statement. This crashed `reverse_qubit_order()` (`KeyError: -1`) and `remove_idle_qubits()` (`KeyError`, [#331](https://github.com/qBraid/pyqasm/issues/331)) on any decomposed gate (e.g. `crz`) whenever the remap was not the identity. Statement constructors in `maps/gates.py` and `Decomposer` now copy their qubit operands so every emitted statement owns its nodes. ([#333](https://github.com/qBraid/pyqasm/issues/333))
- Fixed `remove_idle_qubits(in_place=False)` updating the qubit count on the wrong module: the original module's `num_qubits` was decremented while the returned copy kept the stale pre-removal count. The copy's AST was already correct; only the counters were swapped. ([#336](https://github.com/qBraid/pyqasm/pull/336))
- Fixed `remove_idle_qubits()` raising `KeyError` when the unrolled AST contains operand nodes shared across multiple statements (e.g. the `crz` decomposition) and an idle lower-indexed qubit shifts the register indices. `_remap_qubits` now remaps each operand node exactly once instead of once per statement that references it. ([#332](https://github.com/qBraid/pyqasm/pull/332))
- Fixed `box` duration validation summing `delay` durations across all qubits instead of tracking each qubit's timeline. Delays on disjoint qubits run in parallel, so `box[300ns] { delay[200ns] q[0]; delay[200ns] q[1]; }` was rejected while the identical schedule written as a broadcast delay (`delay[200ns] q;`) was accepted. Delays are now accumulated per qubit and the box is validated against the busiest single timeline; the error message names the offending qubit. Nested boxes now also contribute their declared duration to the enclosing box's timelines (previously the accumulator was reset when an inner box closed, dropping all inner delay accounting). ([#330](https://github.com/qBraid/pyqasm/pull/330))
Expand Down
6 changes: 4 additions & 2 deletions src/pyqasm/decomposer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from pyqasm.exceptions import RebaseError
from pyqasm.maps.decomposition_rules import DECOMPOSITION_RULES, AppliedQubit
from pyqasm.maps.gates import BASIS_GATE_MAP
from pyqasm.maps.gates import BASIS_GATE_MAP, fresh_qubits


class Decomposer:
Expand Down Expand Up @@ -133,7 +133,9 @@ def _get_decomposed_gates(cls, decomposition_rules, statement, gate):
modifiers=[],
name=qasm3_ast.Identifier(name=rule["gate"]),
arguments=arguments,
qubits=qubits,
# copy so the emitted gates do not share operand nodes with each
# other or with the source statement (see #333)
qubits=fresh_qubits(*qubits),
)

decomposed_gates.append(new_gate)
Expand Down
64 changes: 58 additions & 6 deletions src/pyqasm/maps/gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,66 @@

"""

from copy import deepcopy
from typing import Callable

import numpy as np
from openqasm3.ast import FloatLiteral, Identifier, IndexedIdentifier, QuantumGate, QuantumPhase
from openqasm3.ast import (
FloatLiteral,
Identifier,
IndexedIdentifier,
IntegerLiteral,
QuantumGate,
QuantumPhase,
)

from pyqasm.elements import BasisSet, InversionOp
from pyqasm.exceptions import ValidationError, raise_qasm3_error
from pyqasm.linalg import kak_decomposition_angles
from pyqasm.maps.expressions import CONSTANTS_MAP

QubitOperand = IndexedIdentifier | Identifier


def _copy_qubit(qubit: QubitOperand) -> QubitOperand:
"""Return a copy of a single qubit operand that shares no nodes with it.

A shallow ``copy`` is not enough: transforms rewrite the index in place as
``bit.indices[0][0].value = ...``, so the nested ``IntegerLiteral`` has to
be a distinct object. Rebuilding the (small, fully known) node tree is an
order of magnitude cheaper than ``deepcopy``, so the general case is only
used as a fallback for operands that are not a plain ``reg[int]``.
"""
fresh: QubitOperand
if isinstance(qubit, Identifier):
fresh = Identifier(name=qubit.name)
else:
indices = qubit.indices
if not (
len(indices) == 1
and isinstance(indices[0], list)
and len(indices[0]) == 1
and isinstance(indices[0][0], IntegerLiteral)
):
return deepcopy(qubit)
fresh = IndexedIdentifier(
name=Identifier(name=qubit.name.name),
indices=[[IntegerLiteral(value=indices[0][0].value)]],
)
fresh.span = qubit.span
return fresh


def fresh_qubits(*qubits: QubitOperand) -> list[QubitOperand]:
"""Copy qubit operands so every emitted statement owns its nodes.

Decomposition functions pass the same operand nodes to each statement they
emit; without copies, transforms that later rewrite qubit indices in place
(e.g. ``remove_idle_qubits``, ``reverse_qubit_order``) would mutate a
shared node once per referencing statement.
"""
return [_copy_qubit(qubit) for qubit in qubits]


def u3_gate(
theta: int | float,
Expand Down Expand Up @@ -113,7 +163,9 @@ def global_phase_gate(theta: float, qubit_list: list[IndexedIdentifier]) -> list
"""
return [
QuantumPhase(
argument=FloatLiteral(value=theta), qubits=qubit_list, modifiers=[] # type: ignore
argument=FloatLiteral(value=theta),
qubits=fresh_qubits(*qubit_list), # type: ignore
modifiers=[],
)
]

Expand Down Expand Up @@ -702,7 +754,7 @@ def ccx_gate_op(
modifiers=[],
name=Identifier(name="ccx"),
arguments=[],
qubits=[qubit0, qubit1, qubit2],
qubits=fresh_qubits(qubit0, qubit1, qubit2),
)
]

Expand Down Expand Up @@ -905,7 +957,7 @@ def one_qubit_gate_op(gate_name: str, qubit_id: IndexedIdentifier) -> list[Quant
modifiers=[],
name=Identifier(name=gate_name),
arguments=[],
qubits=[qubit_id],
qubits=fresh_qubits(qubit_id),
)
]

Expand All @@ -918,7 +970,7 @@ def one_qubit_rotation_op(
modifiers=[],
name=Identifier(name=gate_name),
arguments=[FloatLiteral(value=rotation)],
qubits=[qubit_id],
qubits=fresh_qubits(qubit_id),
)
]

Expand All @@ -931,7 +983,7 @@ def two_qubit_gate_op(
modifiers=[],
name=Identifier(name=gate_name.lower()),
arguments=[],
qubits=[qubit_id1, qubit_id2],
qubits=fresh_qubits(qubit_id1, qubit_id2),
)
]

Expand Down
124 changes: 124 additions & 0 deletions tests/qasm3/test_transformations.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@

"""

import pytest
from openqasm3.ast import DiscreteSet, Identifier, IndexedIdentifier, IntegerLiteral, Span

from pyqasm.analyzer import Qasm3Analyzer
from pyqasm.elements import BasisSet
from pyqasm.entrypoint import dumps, loads
from pyqasm.maps import QUANTUM_STATEMENTS
from pyqasm.maps.gates import fresh_qubits
from tests.utils import check_unrolled_qasm


Expand Down Expand Up @@ -156,6 +163,123 @@ def test_reverse_qubit_order_qasm3():
check_unrolled_qasm(dumps(module), expected_qasm3_str)


def test_reverse_qubit_order_gate_decomposition():
"""Test reverse_qubit_order on a decomposed gate whose statements previously
shared operand nodes (issue #333)"""
qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
crz(0.5) q[1], q[2];
"""

expected_qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
rz(0.25) q[0];
rx(1.5707963267948966) q[0];
rz(3.141592653589793) q[0];
rx(1.5707963267948966) q[0];
rz(3.141592653589793) q[0];
cx q[1], q[0];
rz(-0.25) q[0];
rx(1.5707963267948966) q[0];
rz(3.141592653589793) q[0];
rx(1.5707963267948966) q[0];
rz(3.141592653589793) q[0];
cx q[1], q[0];
"""

module = loads(qasm3_str)
module.unroll()
module.reverse_qubit_order()
check_unrolled_qasm(dumps(module), expected_qasm3_str)


def _assert_no_shared_operand_nodes(module):
"""Assert no two quantum statements in the unrolled AST share an operand node"""
seen_bits: set[int] = set()
seen_indices: set[int] = set()
for statement in module._unrolled_ast.statements:
if isinstance(statement, QUANTUM_STATEMENTS):
for bit in Qasm3Analyzer.get_op_bit_list(statement):
assert id(bit) not in seen_bits, f"operand node shared: {bit}"
seen_bits.add(id(bit))
index_node = bit.indices[0][0]
assert id(index_node) not in seen_indices, f"index node shared: {bit}"
seen_indices.add(id(index_node))


@pytest.mark.parametrize(
"operation",
[
"crz(0.5) q[1], q[2];",
"crx(0.5) q[0], q[1];",
"c4x q[0], q[1], q[2], q[3], q[4];",
"ecr q[0], q[1];",
"inv @ crz(0.5) q[1], q[2];",
],
)
def test_unroll_emits_fresh_operand_nodes(operation):
"""Test that unroll() never emits statements sharing operand nodes, so in-place
transformations remap each operand exactly once (issues #331, #333)"""
qasm3_str = f"""
OPENQASM 3.0;
include "stdgates.inc";
qubit[5] q;
{operation}
"""
module = loads(qasm3_str)
module.unroll()
_assert_no_shared_operand_nodes(module)


def test_rebase_emits_fresh_operand_nodes():
"""Test that rebase() never emits statements sharing operand nodes (issue #333)"""
qasm3_str = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[3] q;
crz(0.5) q[1], q[2];
"""
module = loads(qasm3_str).rebase(BasisSet.ROTATIONAL_CX)
_assert_no_shared_operand_nodes(module)


@pytest.mark.parametrize(
"qubit",
[
# plain reg[int] operand, rebuilt node by node
IndexedIdentifier(Identifier("q"), [[IntegerLiteral(2)]]),
# physical qubit, kept as a bare Identifier
Identifier("$0"),
# anything else falls back to a deep copy
IndexedIdentifier(Identifier("q"), [DiscreteSet([IntegerLiteral(0), IntegerLiteral(1)])]),
IndexedIdentifier(Identifier("q"), [[IntegerLiteral(0)], [IntegerLiteral(1)]]),
],
)
def test_fresh_qubits_shares_no_nodes(qubit):
"""Test that fresh_qubits returns an equal operand that shares no node with the original"""
qubit.span = Span(1, 0, 1, 4)

(copied,) = fresh_qubits(qubit)

assert copied == qubit
assert copied.span == qubit.span
assert copied is not qubit
if isinstance(qubit, IndexedIdentifier):
assert copied.name is not qubit.name
for copied_dim, original_dim in zip(copied.indices, qubit.indices):
assert copied_dim is not original_dim
copied_exprs = copied_dim.values if isinstance(copied_dim, DiscreteSet) else copied_dim
original_exprs = (
original_dim.values if isinstance(original_dim, DiscreteSet) else original_dim
)
for copied_index, original_index in zip(copied_exprs, original_exprs):
assert copied_index is not original_index


def test_populate_idle_qubits_qasm3():
"""Test the populate idle qubits function for qasm3 string"""

Expand Down
Loading