Repository: Qiskit/qiskit — pull request #14978 (python)
Title: Un-provide `standard_operation` for `PyGate`

<!--\r\n⚠️ If you do not respect this template, your pull request will be closed.\r\n⚠️ Your pull request title should be short detailed and understandable for all.\r\n⚠️ Also, please add a release note file using reno if the change needs to be\r\n  documented in the release notes.\r\n⚠️ If your pull request fixes an open issue, please link to the issue.\r\n\r\n- [x] I have added the tests to cover my changes.\r\n- [x] I have updated the documentation accordingly.\r\n- [x] I have read the CONTRIBUTING document.\r\n-->\r\n\r\n### Summary\r\n\r\nPreviously `PyGate` implemented the method `standard_gate`, which for example allowed to interpret a `PyGate` created for `CRX(controlled_state=0)` as `StandardGate::CRX`, effectively losing the control state and leading to problems down the line.\r\n\r\nFixes #14974.\r\n\r\n### Details and comments\r\n\r\nJake's additional comments: the solution not provide that method at all - if we can’t treat it as a StandardGate during extraction, there’s no real need to guess at other times. There’s at least a couple of reasons we can’t do it:\r\n* not-all-ones control state\r\n* the base_gate of a controlledgate has extra attributes set (e.g. label) and we have tests that assert that the label of the base gate is propagated\r\n\r\n

Resolved issues:
title: `CommutativeInverseCancellation` cancels `CS/CSdg` with `CRX` when control state is |0⟩
body: ### Environment\n\n- **Qiskit version**: 2.1.2\n- **Python version**: 3.13\n- **Operating system**: macOS 15.6\n\n\n### What is happening?\n\n`CommutativeInverseCancellation` assumes that `CS` and its inverse commute with `CRX` and cancels them by moving gates across each other, changing the circuit's semantics. This only occurs when the control state is $\\vert 0 \\rangle$.\n\n### How can we reproduce the issue?\n\n```python\nfrom qiskit import QuantumCircuit\nfrom qiskit.transpiler import PassManager\nfrom qiskit.transpiler.passes import CommutativeInverseCancellation\n\npm = PassManager(CommutativeInverseCancellation())\n\ncircuit = QuantumCircuit(2, 1)\ncircuit.csdg(0, 1, ctrl_state=0)\ncircuit.crx(1, 0, 1, ctrl_state=0)\ncircuit.cs(0, 1, ctrl_state=0)\ncircuit.ry(1, 1)\ncircuit.measure(1, 0)\nprint(circuit)\n\ntrans_circuit = pm.run(circuit)\nprint(trans_circuit)\n\n# Optional: Simulation\nfrom qiskit_aer import Aer\nfrom qiskit import transpile\n\nsim = Aer.get_backend("aer_simulator")\nprint("Original:")\ncircuit = transpile(circuit, sim, optimization_level=0)\nprint(sim.run(circuit).result().get_counts())\nprint("Transpiled:")\ntrans_circuit = transpile(trans_circuit, sim, optimization_level=0)\nprint(sim.run(trans_circuit).result().get_counts())\n```\n```\nq_0: ───o───────o──────o──────────────\n     ┌──┴──┐┌───┴───┐┌─┴─┐┌───────┐┌─┐\nq_1: ┤ Sdg ├┤ Rx(1) ├┤ S ├┤ Ry(1) ├┤M├\n     └─────┘└───────┘└───┘└───────┘└╥┘\nc: 1/═══════════════════════════════╩═\n                                    0 \nq_0: ────o────────────────\n     ┌───┴───┐┌───────┐┌─┐\nq_1: ┤ Rx(1) ├┤ Ry(1) ├┤M├\n     └───────┘└───────┘└╥┘\nc: 1/═══════════════════╩═\n                        0 \nOriginal:\n{'0': 284, '1': 740}\nTranspiled:\n{'0': 674, '1': 350}\n```\n\n### What should happen?\n\n`CS` and `CSdg` should not be cancelled across `CRX`.\n\n### Any suggestions?\n\nThe bug only appears when `CS` and `CSdg` are used with `ctrl_state=0`. In this case, `CommutationChecker` seems to skip the standard gate commutation table, and instead falls back to a matrix-multiplication check. I suspect the issue lies in this fallback path, which incorrectly treats the gates as commuting.

Hints:

Fix patch:
diff --git a/crates/circuit/src/operations.rs b/crates/circuit/src/operations.rs\nindex f6bd3bfedc6c..c2403ce7fef7 100644\n--- a/crates/circuit/src/operations.rs\n+++ b/crates/circuit/src/operations.rs\n@@ -256,7 +256,6 @@ pub trait Operation {\n     fn blocks(&self) -> Vec<CircuitData>;\n     fn matrix(&self, params: &[Param]) -> Option<Array2<Complex64>>;\n     fn definition(&self, params: &[Param]) -> Option<CircuitData>;\n-    fn standard_gate(&self) -> Option<StandardGate>;\n     fn directive(&self) -> bool;\n     fn matrix_as_static_1q(&self, params: &[Param]) -> Option<[[Complex64; 2]; 2]>;\n     fn matrix_as_nalgebra_1q(&self, params: &[Param]) -> Option<Matrix2<Complex64>> {\n@@ -370,17 +369,6 @@ impl Operation for OperationRef<'_> {\n         }\n     }\n     #[inline]\n-    fn standard_gate(&self) -> Option<StandardGate> {\n-        match self {\n-            Self::StandardGate(standard) => standard.standard_gate(),\n-            Self::StandardInstruction(instruction) => instruction.standard_gate(),\n-            Self::Gate(gate) => gate.standard_gate(),\n-            Self::Instruction(instruction) => instruction.standard_gate(),\n-            Self::Operation(operation) => operation.standard_gate(),\n-            Self::Unitary(unitary) => unitary.standard_gate(),\n-        }\n-    }\n-    #[inline]\n     fn directive(&self) -> bool {\n         match self {\n             Self::StandardGate(standard) => standard.directive(),\n@@ -552,10 +540,6 @@ impl Operation for StandardInstruction {\n         None\n     }\n \n-    fn standard_gate(&self) -> Option<StandardGate> {\n-        None\n-    }\n-\n     fn directive(&self) -> bool {\n         match self {\n             StandardInstruction::Barrier(_) => true,\n@@ -2259,10 +2243,6 @@ impl Operation for StandardGate {\n         }\n     }\n \n-    fn standard_gate(&self) -> Option<StandardGate> {\n-        Some(*self)\n-    }\n-\n     fn directive(&self) -> bool {\n         false\n     }\n@@ -2555,9 +2535,6 @@ impl Operation for PyInstruction {\n             }\n         })\n     }\n-    fn standard_gate(&self) -> Option<StandardGate> {\n-        None\n-    }\n \n     fn directive(&self) -> bool {\n         Python::with_gil(|py| -> bool {\n@@ -2659,14 +2636,6 @@ impl Operation for PyGate {\n             }\n         })\n     }\n-    fn standard_gate(&self) -> Option<StandardGate> {\n-        Python::with_gil(|py| -> Option<StandardGate> {\n-            match self.gate.getattr(py, intern!(py, "_standard_gate")) {\n-                Ok(stdgate) => stdgate.extract(py).unwrap_or_default(),\n-                Err(_) => None,\n-            }\n-        })\n-    }\n     fn directive(&self) -> bool {\n         false\n     }\n@@ -2749,10 +2718,6 @@ impl Operation for PyOperation {\n     fn definition(&self, _params: &[Param]) -> Option<CircuitData> {\n         None\n     }\n-    fn standard_gate(&self) -> Option<StandardGate> {\n-        None\n-    }\n-\n     fn directive(&self) -> bool {\n         Python::with_gil(|py| -> bool {\n             match self.operation.getattr(py, intern!(py, "_directive")) {\n@@ -2839,10 +2804,6 @@ impl Operation for UnitaryGate {\n         None\n     }\n \n-    fn standard_gate(&self) -> Option<StandardGate> {\n-        None\n-    }\n-\n     fn directive(&self) -> bool {\n         false\n     }\ndiff --git a/crates/circuit/src/packed_instruction.rs b/crates/circuit/src/packed_instruction.rs\nindex e38b4b759c01..1302f7fe09b5 100644\n--- a/crates/circuit/src/packed_instruction.rs\n+++ b/crates/circuit/src/packed_instruction.rs\n@@ -546,10 +546,6 @@ impl Operation for PackedOperation {\n         self.view().definition(params)\n     }\n     #[inline]\n-    fn standard_gate(&self) -> Option<StandardGate> {\n-        self.view().standard_gate()\n-    }\n-    #[inline]\n     fn directive(&self) -> bool {\n         self.view().directive()\n     }\ndiff --git a/crates/transpiler/src/commutation_checker.rs b/crates/transpiler/src/commutation_checker.rs\nindex f7152e7af4b1..df1514d1fef8 100644\n--- a/crates/transpiler/src/commutation_checker.rs\n+++ b/crates/transpiler/src/commutation_checker.rs\n@@ -395,8 +395,9 @@ impl CommutationChecker {\n         //  * gates we know are in the cache (SUPPORTED_OPS), or\n         //  * standard gates with float params (otherwise we cannot cache them)\n         let is_cachable = |op: &OperationRef, params: &[Param]| {\n-            if let Some(gate) = op.standard_gate() {\n-                SUPPORTED_OP[gate as usize] || params.iter().all(|p| matches!(p, Param::Float(_)))\n+            if let OperationRef::StandardGate(gate) = op {\n+                SUPPORTED_OP[(*gate) as usize]\n+                    || params.iter().all(|p| matches!(p, Param::Float(_)))\n             } else {\n                 false\n             }\n@@ -595,9 +596,9 @@ fn commutation_precheck(\n         return Some(false);\n     }\n \n-    if let Some(gate_1) = op1.standard_gate() {\n-        if let Some(gate_2) = op2.standard_gate() {\n-            if SUPPORTED_OP[gate_1 as usize] && SUPPORTED_OP[gate_2 as usize] {\n+    if let OperationRef::StandardGate(gate_1) = op1 {\n+        if let OperationRef::StandardGate(gate_2) = op2 {\n+            if SUPPORTED_OP[(*gate_1) as usize] && SUPPORTED_OP[(*gate_2) as usize] {\n                 return None;\n             }\n         }\n@@ -678,18 +679,18 @@ fn map_rotation<'a>(\n     params: &'a [Param],\n     tol: f64,\n ) -> (Option<StandardGate>, &'a [Param], bool) {\n-    if let Some(gate) = op.standard_gate() {\n-        if let Some(generator) = SUPPORTED_ROTATIONS[gate as usize] {\n+    if let OperationRef::StandardGate(gate) = op {\n+        if let Some(generator) = SUPPORTED_ROTATIONS[(*gate) as usize] {\n             // If the rotation angle is below the tolerance, the gate is assumed to\n             // commute with everything, and we simply return the operation with the flag that\n             // it commutes trivially.\n             if let Param::Float(angle) = params[0] {\n-                let (tr_over_dim, dim) = gate_metrics::rotation_trace_and_dim(gate, angle)\n+                let (tr_over_dim, dim) = gate_metrics::rotation_trace_and_dim(*gate, angle)\n                     .expect("All rotation should be covered at this point");\n                 let gate_fidelity = tr_over_dim.abs().powi(2);\n                 let process_fidelity = (dim * gate_fidelity + 1.) / (dim + 1.);\n                 if (1. - process_fidelity).abs() <= tol {\n-                    return (Some(gate), params, true);\n+                    return (Some(*gate), params, true);\n                 };\n             };\n \ndiff --git a/releasenotes/notes/fix-commutation-checking-with-not-all-one-states-45fdd8a779ae4277.yaml b/releasenotes/notes/fix-commutation-checking-with-not-all-one-states-45fdd8a779ae4277.yaml\nnew file mode 100644\nindex 000000000000..3098768a56c7\n--- /dev/null\n+++ b/releasenotes/notes/fix-commutation-checking-with-not-all-one-states-45fdd8a779ae4277.yaml\n@@ -0,0 +1,7 @@\n+---\n+fixes:\n+  - |\n+    Fixed a problem in `~.CommutationChecker`, where standard controlled gates\n+    were not handled correctly if they were controlled on something other than\n+    the all-ones state.\n+    Fixed `#14974 <https://github.com/Qiskit/qiskit/issues/14974>`__\n

Test patch:
diff --git a/test/python/transpiler/test_commutative_inverse_cancellation.py b/test/python/transpiler/test_commutative_inverse_cancellation.py\nindex c4f995deb3c4..61787047ccf8 100644\n--- a/test/python/transpiler/test_commutative_inverse_cancellation.py\n+++ b/test/python/transpiler/test_commutative_inverse_cancellation.py\n@@ -925,6 +925,24 @@ def test_initialize(self):\n         # The pass should run successfully but not reduce anything\n         self.assertEqual(circuit, tqc)\n \n+    def test_controlled_state_at_zero(self):\n+        """Regression test of #14974.\n+\n+        Two gates with not-all-ones control-states were wrongly\n+        detected to commute, leading to invalid simplification.\n+        """\n+        circuit = QuantumCircuit(2)\n+        circuit.csdg(0, 1, ctrl_state=0)\n+        circuit.crx(1, 0, 1, ctrl_state=0)\n+        circuit.cs(0, 1, ctrl_state=0)\n+        circuit.ry(1, 1)\n+\n+        pm = PassManager(CommutativeInverseCancellation())\n+        tqc = pm.run(circuit)\n+\n+        # The pass should run successfully but not reduce anything\n+        self.assertEqual(circuit, tqc)\n+\n \n if __name__ == "__main__":\n     unittest.main()\n