From 1ea2ef20bca4803665e0c07d47b4071871516c8b Mon Sep 17 00:00:00 2001 From: TSS99 Date: Sun, 3 May 2026 15:40:04 +0530 Subject: [PATCH 1/4] Fix inverse cancellation qubit check --- crates/transpiler/src/passes/inverse_cancellation.rs | 6 +++--- .../notes/fix-inverse-cancellation-qubits-15855.yaml | 6 ++++++ test/python/transpiler/test_inverse_cancellation.py | 9 +++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/fix-inverse-cancellation-qubits-15855.yaml diff --git a/crates/transpiler/src/passes/inverse_cancellation.rs b/crates/transpiler/src/passes/inverse_cancellation.rs index dd5be9068983..e18c1128e475 100644 --- a/crates/transpiler/src/passes/inverse_cancellation.rs +++ b/crates/transpiler/src/passes/inverse_cancellation.rs @@ -265,10 +265,10 @@ fn std_inverse_pairs(dag: &mut DAGCircuit) { unreachable!("Not an op node"); }; if inst.qubits == next_inst.qubits - && (inst.op.try_standard_gate() == Some(gate_0) + && ((inst.op.try_standard_gate() == Some(gate_0) && next_inst.op.try_standard_gate() == Some(gate_1)) - || (inst.op.try_standard_gate() == Some(gate_1) - && next_inst.op.try_standard_gate() == Some(gate_0)) + || (inst.op.try_standard_gate() == Some(gate_1) + && next_inst.op.try_standard_gate() == Some(gate_0))) { dag.remove_op_node(nodes[i]); dag.remove_op_node(nodes[i + 1]); diff --git a/releasenotes/notes/fix-inverse-cancellation-qubits-15855.yaml b/releasenotes/notes/fix-inverse-cancellation-qubits-15855.yaml new file mode 100644 index 000000000000..ff77b89b2765 --- /dev/null +++ b/releasenotes/notes/fix-inverse-cancellation-qubits-15855.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + Fixed a bug in :class:`.InverseCancellation` where default inverse gate + pairs in reverse order could be cancelled even when they acted on different + qubits. diff --git a/test/python/transpiler/test_inverse_cancellation.py b/test/python/transpiler/test_inverse_cancellation.py index 5a33ea29ca4b..802aa67e2b4a 100644 --- a/test/python/transpiler/test_inverse_cancellation.py +++ b/test/python/transpiler/test_inverse_cancellation.py @@ -379,6 +379,15 @@ def test_some_inverse_pairs(self, gates_to_cancel): self.assertNotIn("t", new_circ.count_ops()) self.assertNotIn("tdg", new_circ.count_ops()) + def test_default_inverse_pairs_do_not_cancel_on_different_qubits(self): + """Test default inverse pairs cancel only when qubits match.""" + qc = QuantumCircuit(2) + qc.csdg(0, 1) + qc.cs(1, 0) + inverse_pass = InverseCancellation() + new_circ = inverse_pass(qc) + self.assertEqual(qc, new_circ) + @ddt.data([HGate(), CXGate(), CZGate(), (TGate(), TdgGate())], None) def test_some_inverse_and_cancelled(self, gates_to_cancel): """Test when there are some but not all pairs to cancel.""" From 48dd814d8591126a50be1d05c95d77c08e00370b Mon Sep 17 00:00:00 2001 From: TSS99 Date: Sun, 3 May 2026 23:15:21 +0530 Subject: [PATCH 2/4] tests: improve inverse-cancellation qubit-check coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the docstring on the existing regression test to explain the operator-precedence bug that was fixed (a && b || c vs a && (b || c)). Add a complementary test that verifies a reversed default inverse pair (cs followed by csdg on the same qubits) is still correctly cancelled — confirming the fix didn't break the happy path. Co-Authored-By: Claude Sonnet 4.6 --- .../transpiler/test_inverse_cancellation.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test/python/transpiler/test_inverse_cancellation.py b/test/python/transpiler/test_inverse_cancellation.py index 802aa67e2b4a..1de7eccb2cfb 100644 --- a/test/python/transpiler/test_inverse_cancellation.py +++ b/test/python/transpiler/test_inverse_cancellation.py @@ -380,7 +380,12 @@ def test_some_inverse_pairs(self, gates_to_cancel): self.assertNotIn("tdg", new_circ.count_ops()) def test_default_inverse_pairs_do_not_cancel_on_different_qubits(self): - """Test default inverse pairs cancel only when qubits match.""" + """Reversed default inverse pair must not cancel when qubit order differs. + + Before the fix, ``csdg(0, 1)`` followed by ``cs(1, 0)`` was incorrectly + removed by the fast path because the qubit-equality guard did not cover + the reversed-pair branch (``a && b || c`` instead of ``a && (b || c)``). + """ qc = QuantumCircuit(2) qc.csdg(0, 1) qc.cs(1, 0) @@ -388,6 +393,15 @@ def test_default_inverse_pairs_do_not_cancel_on_different_qubits(self): new_circ = inverse_pass(qc) self.assertEqual(qc, new_circ) + def test_default_inverse_pairs_cancel_reversed_on_same_qubits(self): + """Reversed default inverse pair must cancel when qubit order matches.""" + qc = QuantumCircuit(2) + qc.cs(0, 1) + qc.csdg(0, 1) + inverse_pass = InverseCancellation() + new_circ = inverse_pass(qc) + self.assertEqual(new_circ, QuantumCircuit(2)) + @ddt.data([HGate(), CXGate(), CZGate(), (TGate(), TdgGate())], None) def test_some_inverse_and_cancelled(self, gates_to_cancel): """Test when there are some but not all pairs to cancel.""" From 0838f9fe6943d7adf5112d2c455c09be8f869d79 Mon Sep 17 00:00:00 2001 From: Tilock Sadhukhan Date: Mon, 4 May 2026 16:33:58 +0530 Subject: [PATCH 3/4] Preserve CS/CSdg symmetric cancellation; sharpen test --- .../src/passes/inverse_cancellation.rs | 20 +++++++++----- .../transpiler/test_inverse_cancellation.py | 27 +++++++++---------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/crates/transpiler/src/passes/inverse_cancellation.rs b/crates/transpiler/src/passes/inverse_cancellation.rs index e18c1128e475..a22b19563e31 100644 --- a/crates/transpiler/src/passes/inverse_cancellation.rs +++ b/crates/transpiler/src/passes/inverse_cancellation.rs @@ -248,6 +248,9 @@ fn std_inverse_pairs(dag: &mut DAGCircuit) { { continue; } + // Some inverse pairs (CS/CSdg) are symmetric in their qubit order, so + // a reversed-qargs occurrence of the partner still cancels. + let pair_symmetric_2q = matches!(gate_0, StandardGate::CS); let filter = |inst: &PackedInstruction| -> bool { match inst.op.view() { OperationRef::StandardGate(gate) => gate == gate_0 || gate == gate_1, @@ -264,12 +267,17 @@ fn std_inverse_pairs(dag: &mut DAGCircuit) { let NodeType::Operation(next_inst) = &dag[nodes[i + 1]] else { unreachable!("Not an op node"); }; - if inst.qubits == next_inst.qubits - && ((inst.op.try_standard_gate() == Some(gate_0) - && next_inst.op.try_standard_gate() == Some(gate_1)) - || (inst.op.try_standard_gate() == Some(gate_1) - && next_inst.op.try_standard_gate() == Some(gate_0))) - { + let pair_match = (inst.op.try_standard_gate() == Some(gate_0) + && next_inst.op.try_standard_gate() == Some(gate_1)) + || (inst.op.try_standard_gate() == Some(gate_1) + && next_inst.op.try_standard_gate() == Some(gate_0)); + let qargs_match = inst.qubits == next_inst.qubits + || (pair_symmetric_2q && { + let a = dag.get_qargs(inst.qubits); + let b = dag.get_qargs(next_inst.qubits); + a.len() == 2 && b.len() == 2 && a[0] == b[1] && a[1] == b[0] + }); + if pair_match && qargs_match { dag.remove_op_node(nodes[i]); dag.remove_op_node(nodes[i + 1]); i += 2; diff --git a/test/python/transpiler/test_inverse_cancellation.py b/test/python/transpiler/test_inverse_cancellation.py index 1de7eccb2cfb..35fbe3f99561 100644 --- a/test/python/transpiler/test_inverse_cancellation.py +++ b/test/python/transpiler/test_inverse_cancellation.py @@ -379,27 +379,26 @@ def test_some_inverse_pairs(self, gates_to_cancel): self.assertNotIn("t", new_circ.count_ops()) self.assertNotIn("tdg", new_circ.count_ops()) - def test_default_inverse_pairs_do_not_cancel_on_different_qubits(self): - """Reversed default inverse pair must not cancel when qubit order differs. + def test_cs_csdg_overlapping_qubits_not_cancelled(self): + """csdg(0,1) followed by cs(1,2) must not cancel. - Before the fix, ``csdg(0, 1)`` followed by ``cs(1, 0)`` was incorrectly - removed by the fast path because the qubit-equality guard did not cover - the reversed-pair branch (``a && b || c`` instead of ``a && (b || c)``). + Reproducer for the bug from #15855: the reversed-pair branch in + ``std_inverse_pairs`` previously skipped the qubit-equality guard, so + these two gates were removed even though they act on different qubit + sets and do not compose to identity. """ - qc = QuantumCircuit(2) + qc = QuantumCircuit(3) qc.csdg(0, 1) - qc.cs(1, 0) - inverse_pass = InverseCancellation() - new_circ = inverse_pass(qc) + qc.cs(1, 2) + new_circ = InverseCancellation()(qc) self.assertEqual(qc, new_circ) - def test_default_inverse_pairs_cancel_reversed_on_same_qubits(self): - """Reversed default inverse pair must cancel when qubit order matches.""" + def test_cs_csdg_reversed_qargs_still_cancelled(self): + """cs is symmetric, so cs(0,1) and csdg(1,0) still cancel.""" qc = QuantumCircuit(2) qc.cs(0, 1) - qc.csdg(0, 1) - inverse_pass = InverseCancellation() - new_circ = inverse_pass(qc) + qc.csdg(1, 0) + new_circ = InverseCancellation()(qc) self.assertEqual(new_circ, QuantumCircuit(2)) @ddt.data([HGate(), CXGate(), CZGate(), (TGate(), TdgGate())], None) From 11560b5d42183c8df5277cff6c6dbbbbcbb73dfc Mon Sep 17 00:00:00 2001 From: TSS99 Date: Thu, 7 May 2026 14:02:19 +0530 Subject: [PATCH 4/4] Refine CS inverse cancellation regression --- .../src/passes/inverse_cancellation.rs | 12 ++++---- ...fix-inverse-cancellation-qubits-15855.yaml | 6 ++-- .../transpiler/test_inverse_cancellation.py | 30 ++++++------------- 3 files changed, 17 insertions(+), 31 deletions(-) diff --git a/crates/transpiler/src/passes/inverse_cancellation.rs b/crates/transpiler/src/passes/inverse_cancellation.rs index a22b19563e31..42594b7e8b46 100644 --- a/crates/transpiler/src/passes/inverse_cancellation.rs +++ b/crates/transpiler/src/passes/inverse_cancellation.rs @@ -248,9 +248,7 @@ fn std_inverse_pairs(dag: &mut DAGCircuit) { { continue; } - // Some inverse pairs (CS/CSdg) are symmetric in their qubit order, so - // a reversed-qargs occurrence of the partner still cancels. - let pair_symmetric_2q = matches!(gate_0, StandardGate::CS); + let pair_symmetric_2q = matches!([gate_0, gate_1], [StandardGate::CS, StandardGate::CSdg]); let filter = |inst: &PackedInstruction| -> bool { match inst.op.view() { OperationRef::StandardGate(gate) => gate == gate_0 || gate == gate_1, @@ -267,10 +265,10 @@ fn std_inverse_pairs(dag: &mut DAGCircuit) { let NodeType::Operation(next_inst) = &dag[nodes[i + 1]] else { unreachable!("Not an op node"); }; - let pair_match = (inst.op.try_standard_gate() == Some(gate_0) - && next_inst.op.try_standard_gate() == Some(gate_1)) - || (inst.op.try_standard_gate() == Some(gate_1) - && next_inst.op.try_standard_gate() == Some(gate_0)); + let inst_gate = inst.op.try_standard_gate(); + let next_gate = next_inst.op.try_standard_gate(); + let pair_match = (inst_gate == Some(gate_0) && next_gate == Some(gate_1)) + || (inst_gate == Some(gate_1) && next_gate == Some(gate_0)); let qargs_match = inst.qubits == next_inst.qubits || (pair_symmetric_2q && { let a = dag.get_qargs(inst.qubits); diff --git a/releasenotes/notes/fix-inverse-cancellation-qubits-15855.yaml b/releasenotes/notes/fix-inverse-cancellation-qubits-15855.yaml index ff77b89b2765..2f722b4b06de 100644 --- a/releasenotes/notes/fix-inverse-cancellation-qubits-15855.yaml +++ b/releasenotes/notes/fix-inverse-cancellation-qubits-15855.yaml @@ -1,6 +1,6 @@ --- fixes: - | - Fixed a bug in :class:`.InverseCancellation` where default inverse gate - pairs in reverse order could be cancelled even when they acted on different - qubits. + Fixed :class:`.InverseCancellation` so the default :class:`.CSGate` and + :class:`.CSdgGate` inverse pair is cancelled consistently when the two + gates act on the same qubits in opposite order. diff --git a/test/python/transpiler/test_inverse_cancellation.py b/test/python/transpiler/test_inverse_cancellation.py index 35fbe3f99561..c3cf06563f27 100644 --- a/test/python/transpiler/test_inverse_cancellation.py +++ b/test/python/transpiler/test_inverse_cancellation.py @@ -379,27 +379,15 @@ def test_some_inverse_pairs(self, gates_to_cancel): self.assertNotIn("t", new_circ.count_ops()) self.assertNotIn("tdg", new_circ.count_ops()) - def test_cs_csdg_overlapping_qubits_not_cancelled(self): - """csdg(0,1) followed by cs(1,2) must not cancel. - - Reproducer for the bug from #15855: the reversed-pair branch in - ``std_inverse_pairs`` previously skipped the qubit-equality guard, so - these two gates were removed even though they act on different qubit - sets and do not compose to identity. - """ - qc = QuantumCircuit(3) - qc.csdg(0, 1) - qc.cs(1, 2) - new_circ = InverseCancellation()(qc) - self.assertEqual(qc, new_circ) - - def test_cs_csdg_reversed_qargs_still_cancelled(self): - """cs is symmetric, so cs(0,1) and csdg(1,0) still cancel.""" - qc = QuantumCircuit(2) - qc.cs(0, 1) - qc.csdg(1, 0) - new_circ = InverseCancellation()(qc) - self.assertEqual(new_circ, QuantumCircuit(2)) + def test_cs_csdg_cancel_with_reversed_qargs_in_both_orders(self): + """CS/CSdg are symmetric in qubit order, so both inverse orders cancel.""" + for first, second in (("csdg", "cs"), ("cs", "csdg")): + with self.subTest(first=first, second=second): + qc = QuantumCircuit(2) + getattr(qc, first)(0, 1) + getattr(qc, second)(1, 0) + new_circ = InverseCancellation()(qc) + self.assertEqual(new_circ, QuantumCircuit(2)) @ddt.data([HGate(), CXGate(), CZGate(), (TGate(), TdgGate())], None) def test_some_inverse_and_cancelled(self, gates_to_cancel):