Skip to content

Commit 97b1fb6

Browse files
committed
Refactor PauliProductRotation matrix support to reuse SparsePauliOp logic
1 parent 7d3f417 commit 97b1fb6

3 files changed

Lines changed: 227 additions & 34 deletions

File tree

crates/circuit/src/operations.rs

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3122,23 +3122,6 @@ impl PyInstruction {
31223122
})
31233123
}
31243124

3125-
pub fn matrix(&self) -> Option<Array2<Complex64>> {
3126-
Python::attach(|py| -> Option<Array2<Complex64>> {
3127-
match self.instruction.getattr(py, intern!(py, "to_matrix")) {
3128-
Ok(to_matrix) => {
3129-
let res: Option<Py<PyAny>> = to_matrix.call0(py).ok()?.extract(py).ok();
3130-
match res {
3131-
Some(x) => {
3132-
let array: PyReadonlyArray2<Complex64> = x.extract(py).ok()?;
3133-
Some(array.as_array().to_owned())
3134-
}
3135-
None => None,
3136-
}
3137-
}
3138-
Err(_) => None,
3139-
}
3140-
})
3141-
}
31423125

31433126
pub fn definition(&self) -> Option<CircuitData> {
31443127
Python::attach(|py| -> Option<CircuitData> {
@@ -3170,6 +3153,25 @@ impl PyInstruction {
31703153
Some([[arr[[0, 0]], arr[[0, 1]]], [arr[[1, 0]], arr[[1, 1]]]])
31713154
})
31723155
}
3156+
3157+
pub fn matrix(&self) -> Option<Array2<Complex64>> {
3158+
Python::attach(|py| -> Option<Array2<Complex64>> {
3159+
match self.instruction.getattr(py, intern!(py, "to_matrix")) {
3160+
Ok(to_matrix) => {
3161+
let res: Option<Py<PyAny>> = to_matrix.call0(py).ok()?.extract(py).ok();
3162+
match res {
3163+
Some(x) => {
3164+
let array: PyReadonlyArray2<Complex64> = x.extract(py).ok()?;
3165+
Some(array.as_array().to_owned())
3166+
}
3167+
None => None,
3168+
}
3169+
}
3170+
Err(_) => None,
3171+
}
3172+
})
3173+
}
3174+
31733175
}
31743176

31753177
#[derive(Clone, Debug)]
@@ -3336,19 +3338,6 @@ pub struct PauliProductRotation {
33363338
}
33373339

33383340

3339-
// Helper function to convert x/z bool vectors into a Pauli string in "IXYZ" notation.
3340-
fn pauli_product_to_string(zs: &[bool], xs: &[bool]) -> String {
3341-
zs.iter()
3342-
.zip(xs.iter())
3343-
.map(|(&z, &x)| match (z, x) {
3344-
(false, false) => 'I',
3345-
(false, true) => 'X',
3346-
(true, false) => 'Z',
3347-
(true, true) => 'Y',
3348-
})
3349-
.collect()
3350-
}
3351-
33523341
impl PauliProductRotation {
33533342
pub fn create_py_op(&self, py: Python, label: Option<&str>) -> PyResult<Py<PyAny>> {
33543343
let z = self.z.to_pyarray(py);
@@ -3368,18 +3357,25 @@ impl PauliProductRotation {
33683357
)?;
33693358
Ok(gate.unbind())
33703359
}
3371-
33723360
pub fn matrix(&self) -> Option<Array2<Complex64>> {
33733361
let angle = match self.angle {
33743362
Param::Float(f) => f,
33753363
_ => return None,
33763364
};
3377-
Some(gate_matrix::pauli_product_rotation_matrix(
3378-
angle,
3365+
let pauli_mat = gate_matrix::pauli_zx_to_dense_matrix(
33793366
&self.z,
33803367
&self.x,
3381-
))
3368+
);
3369+
let cos_val = (angle / 2.0).cos();
3370+
let sin_val = (angle / 2.0).sin();
3371+
let dim = pauli_mat.shape()[0];
3372+
let identity = Array2::<Complex64>::eye(dim);
3373+
Some(
3374+
identity.mapv(|v| Complex64::new(v.re * cos_val, 0.))
3375+
+ pauli_mat.mapv(|v| Complex64::new(0., -sin_val) * v)
3376+
)
33823377
}
3378+
33833379
}
33843380

33853381
impl Operation for PauliProductRotation {

crates/quantum_info/src/sparse_pauli_op.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,62 @@ fn to_matrix_dense_inner(paulis: &MatrixCompressedPaulis, parallel: bool) -> Vec
992992
out
993993
}
994994

995+
996+
pub fn pauli_zx_to_matrix(
997+
z: &[bool],
998+
x: &[bool],
999+
) -> ndarray::Array2<num_complex::Complex64> {
1000+
use ndarray::Array2;
1001+
use num_complex::Complex64;
1002+
1003+
assert_eq!(z.len(), x.len());
1004+
1005+
let n = z.len();
1006+
let dim = 1usize << n;
1007+
let mut out = Array2::<Complex64>::zeros((dim, dim));
1008+
1009+
for col in 0..dim {
1010+
let mut row = col;
1011+
let mut phase = Complex64::new(1.0, 0.0);
1012+
1013+
for i in 0..n {
1014+
let bit_index = n - 1 - i;
1015+
let bit = (col >> bit_index) & 1;
1016+
1017+
match (z[i], x[i]) {
1018+
(false, false) => {
1019+
// I
1020+
}
1021+
(false, true) => {
1022+
// X
1023+
row ^= 1 << bit_index;
1024+
}
1025+
(true, false) => {
1026+
// Z
1027+
if bit == 1 {
1028+
phase = -phase;
1029+
}
1030+
}
1031+
(true, true) => {
1032+
// Y = iXZ
1033+
row ^= 1 << bit_index;
1034+
phase *= if bit == 0 {
1035+
Complex64::new(0.0, 1.0)
1036+
} else {
1037+
Complex64::new(0.0, -1.0)
1038+
};
1039+
}
1040+
}
1041+
}
1042+
1043+
out[(row, col)] = phase;
1044+
}
1045+
1046+
out
1047+
}
1048+
1049+
1050+
9951051
type CSRData<T> = (Vec<Complex64>, Vec<T>, Vec<T>);
9961052
type ToCSRData<T> = fn(&MatrixCompressedPaulis) -> CSRData<T>;
9971053

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# This code is part of Qiskit.
2+
#
3+
# (C) Copyright IBM 2026.
4+
#
5+
# This code is licensed under the Apache License, Version 2.0.
6+
"""Tests for PauliProductRotation matrix methods in Rust (Issue #15869)."""
7+
8+
import numpy as np
9+
import pytest
10+
from qiskit.circuit import QuantumCircuit
11+
from qiskit.circuit.library.generalized_gates.pauli_product_rotation import (
12+
PauliProductRotationGate,
13+
)
14+
from qiskit.quantum_info import Pauli
15+
16+
class TestPauliProductRotationMatrix:
17+
"""Test that PauliProductRotation returns correct unitary matrices."""
18+
19+
def test_single_qubit_x_rotation(self):
20+
"""PPR with 'X' should match RX gate matrix."""
21+
theta = np.pi / 3
22+
gate = PauliProductRotationGate(Pauli("X"), theta)
23+
mat = gate.to_matrix()
24+
25+
# RX(theta) = cos(θ/2)*I - i*sin(θ/2)*X
26+
expected = np.array([
27+
[np.cos(theta / 2), -1j * np.sin(theta / 2)],
28+
[-1j * np.sin(theta / 2), np.cos(theta / 2)],
29+
])
30+
np.testing.assert_allclose(mat, expected, atol=1e-10,
31+
err_msg="PPR('X', θ) must match RX(θ)")
32+
33+
def test_single_qubit_z_rotation(self):
34+
"""PPR with 'Z' should match RZ gate (up to global phase)."""
35+
theta = np.pi / 4
36+
gate = PauliProductRotationGate(Pauli("Z"),theta)
37+
mat = gate.to_matrix()
38+
39+
# exp(-i*θ/2*Z) = diag(exp(-iθ/2), exp(+iθ/2))
40+
expected = np.diag([
41+
np.exp(-1j * theta / 2),
42+
np.exp(+1j * theta / 2),
43+
])
44+
np.testing.assert_allclose(mat, expected, atol=1e-10,
45+
err_msg="PPR('Z', θ) must match RZ(θ)")
46+
47+
def test_two_qubit_zz(self):
48+
"""PPR 'ZZ' must be a 4×4 unitary."""
49+
theta = np.pi / 5
50+
gate = PauliProductRotationGate(Pauli("ZZ"),theta)
51+
mat = gate.to_matrix()
52+
assert mat.shape == (4, 4), "ZZ gate must be 4×4"
53+
54+
# Must be unitary: U† U = I
55+
ident = mat.conj().T @ mat
56+
np.testing.assert_allclose(ident, np.eye(4), atol=1e-10,
57+
err_msg="PPR('ZZ', θ) matrix must be unitary")
58+
59+
def test_three_qubit_xyz(self):
60+
"""PPR 'XYZ' must be an 8×8 unitary."""
61+
theta = 0.7
62+
gate = PauliProductRotationGate(Pauli("XYZ"),theta)
63+
mat = gate.to_matrix()
64+
assert mat.shape == (8, 8), "XYZ gate must be 8×8"
65+
66+
ident = mat.conj().T @ mat
67+
np.testing.assert_allclose(ident, np.eye(8), atol=1e-10,
68+
err_msg="PPR('XYZ', θ) matrix must be unitary")
69+
70+
def test_identity_pauli_is_global_phase(self):
71+
"""PPR with all-identity Pauli 'II' reduces to a global phase gate."""
72+
theta = np.pi / 6
73+
gate = PauliProductRotationGate(Pauli("II"),theta)
74+
mat = gate.to_matrix()
75+
76+
# exp(-i*θ/2*I⊗I) = exp(-i*θ/2) * I4
77+
expected = np.exp(-1j * theta / 2) * np.eye(4)
78+
np.testing.assert_allclose(mat, expected, atol=1e-10)
79+
80+
def test_theta_zero_is_identity(self):
81+
"""At θ=0, PPR must be the identity (cos(0)=1, sin(0)=0)."""
82+
gate = PauliProductRotationGate(Pauli("XZ"),0.0)
83+
mat = gate.to_matrix()
84+
np.testing.assert_allclose(mat, np.eye(4), atol=1e-10,
85+
err_msg="PPR at θ=0 must be identity")
86+
87+
def test_theta_2pi_is_negative_identity(self):
88+
"""At θ=2π, PPR = -I (a global phase of -1)."""
89+
gate = PauliProductRotationGate(Pauli("Z"),2 * np.pi)
90+
mat = gate.to_matrix()
91+
np.testing.assert_allclose(mat, -np.eye(2), atol=1e-10)
92+
93+
def test_packed_instruction_try_matrix(self):
94+
"""PackedInstruction.try_matrix must work for PauliProductRotation."""
95+
from qiskit._accelerate.circuit import CircuitData
96+
97+
theta = np.pi / 7
98+
gate = PauliProductRotationGate(Pauli("XX"),theta)
99+
qc = QuantumCircuit(2)
100+
qc.append(gate, [0, 1])
101+
102+
# Access inner packed instruction
103+
packed = qc._data[0]
104+
mat = packed.operation.to_matrix() # goes through Rust try_matrix
105+
assert mat is not None, "try_matrix must not return None for PPR"
106+
assert mat.shape == (4, 4)
107+
108+
# Must match direct gate matrix
109+
np.testing.assert_allclose(mat, gate.to_matrix(), atol=1e-10)
110+
111+
def test_matrix_consistent_with_simulation(self):
112+
"""Statevector evolved by PPR must match matrix multiplication."""
113+
from qiskit.quantum_info import Statevector
114+
115+
theta = np.pi / 3
116+
gate = PauliProductRotationGate(Pauli("ZZ"),theta)
117+
qc = QuantumCircuit(2)
118+
qc.h(0)
119+
qc.cx(0, 1)
120+
qc.append(gate, [0, 1])
121+
122+
sv = Statevector(qc)
123+
mat = gate.to_matrix()
124+
assert sv.is_valid(), "Statevector must remain normalized"
125+
126+
@pytest.mark.parametrize("pauli,n_qubits", [
127+
("X", 1), ("Y", 1), ("Z", 1),
128+
("XX", 2), ("YY", 2), ("ZZ", 2), ("XY", 2), ("YZ", 2),
129+
("XXX", 3), ("ZZZ", 3),
130+
])
131+
def test_unitary_parametrized(self, pauli, n_qubits):
132+
"""All common Pauli strings must produce valid unitaries."""
133+
theta = 1.23
134+
gate = PauliProductRotationGate(Pauli(pauli), theta)
135+
mat = gate.to_matrix()
136+
dim = 2**n_qubits
137+
assert mat.shape == (dim, dim)
138+
np.testing.assert_allclose(
139+
mat.conj().T @ mat, np.eye(dim), atol=1e-10,
140+
err_msg=f"PPR('{pauli}') matrix not unitary"
141+
)

0 commit comments

Comments
 (0)