Skip to content

Commit 4a0ff59

Browse files
Add magnitude approximation protocol (#1750)
- adds the magnitude approximation protocol to approximate general unitaries - fixes the docstring for the Z-rotation protocols to be $e^{i\theta Z}$ ( $= Rz(-2\theta)$ ) instead of $Rz(2\theta)$
1 parent 8512deb commit 4a0ff59

15 files changed

Lines changed: 338 additions & 32 deletions

qualtran/rotation_synthesis/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
# limitations under the License.
1414

1515
from qualtran.rotation_synthesis.math_config import NumpyConfig, with_dps
16-
from qualtran.rotation_synthesis.protocols.clifford_t_synthesis import (
16+
from qualtran.rotation_synthesis.protocols import (
1717
diagonal_unitary_approx,
1818
fallback_protocol,
19+
magnitude_approx,
1920
mixed_diagonal_protocol,
2021
mixed_fallback_protocol,
2122
)

qualtran/rotation_synthesis/channels/channel.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
from __future__ import annotations
1616

1717
import abc
18-
from typing import Optional, Sequence
18+
from typing import Optional, Sequence, Union
1919

2020
import attrs
21+
import numpy as np
2122

2223
import qualtran.rotation_synthesis._typing as rst
2324
import qualtran.rotation_synthesis.math_config as mc
@@ -33,7 +34,7 @@ def expected_num_ts(self, config: mc.MathConfig) -> rst.Real: ...
3334

3435
@abc.abstractmethod
3536
def diamond_norm_distance_to_rz(self, theta: rst.Real, config: mc.MathConfig) -> rst.Real:
36-
r"""Returns the diamond norm distance to $Rz(2\theta)$."""
37+
r"""Returns the diamond norm distance to $e^{i\theta Z}$."""
3738

3839

3940
@attrs.frozen
@@ -110,6 +111,44 @@ def from_sequence(cls, seq: Sequence[str], twirl: bool = False) -> UnitaryChanne
110111
n = sum(g.startswith("T") for g in seq)
111112
return UnitaryChannel(u.matrix[0, 0], u.matrix[1, 0], n, twirl)
112113

114+
def diamond_norm_distance_to_unitary(
115+
self, unitary: np.ndarray, config: mc.MathConfig
116+
) -> rst.Real:
117+
r"""Returns the diamond norm distance between self and the given unitary.
118+
119+
From Theorem B.1 of arxiv:2203.10064, the diamond norm distance between two untiaries
120+
$U, V$ is $|v_1 - v_0|$ where $v_i$ are the eigen values of $V^\dagger U$. Geometrically
121+
this is the diameter of the smallest disc in the complex plane that contains both
122+
eigenvalues.
123+
"""
124+
# W = V^\dagger U
125+
w = self.to_matrix().adjoint().numpy(config) @ unitary
126+
# Compute the eigen values of W
127+
a = config.one
128+
b = -w[0, 0] - w[1, 1]
129+
c = w[0, 0] * w[1, 1] - w[0, 1] * w[1, 0]
130+
d = config.sqrt(b**2 - 4 * a * c)
131+
eigv0, eigv1 = [(-b - d) / (2 * a), (-b + d) / (2 * a)]
132+
# Compute the norm of the difference.
133+
diameter_vec = eigv1 - eigv0
134+
return config.sqrt(diameter_vec.real**2 + diameter_vec.imag**2)
135+
136+
@classmethod
137+
def from_unitaries(
138+
cls, *unitaries: Union[UnitaryChannel, su2_ct.SU2CliffordT]
139+
) -> UnitaryChannel:
140+
if not unitaries:
141+
raise ValueError('at least one unitary should be provided')
142+
143+
unitary = su2_ct.ISqrt2
144+
for u in unitaries:
145+
if isinstance(u, UnitaryChannel):
146+
unitary = unitary @ u.to_matrix()
147+
else:
148+
unitary = unitary @ u
149+
unitary = unitary.rescale()
150+
return UnitaryChannel(unitary.matrix[0, 0], unitary.matrix[1, 0], unitary.num_t_gates())
151+
113152

114153
@attrs.frozen
115154
class ProjectiveChannel(Channel):

qualtran/rotation_synthesis/channels/channel_test.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ def test_unitary_from_sequence(gates):
4646
def test_diamond_distance_for_unitary(gates, theta, distance):
4747
c = ch.UnitaryChannel.from_sequence(gates)
4848
np.testing.assert_allclose(c.diamond_norm_distance_to_rz(theta, mc.NumpyConfig), distance)
49+
u = np.zeros((2, 2), complex)
50+
u[1, 1] = np.exp(-1j * theta)
51+
u[0, 0] = u[1, 1].conjugate()
52+
np.testing.assert_allclose(c.diamond_norm_distance_to_unitary(u, mc.NumpyConfig), distance)
4953

5054

5155
@pytest.mark.parametrize(

qualtran/rotation_synthesis/lattice/geometry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ def plot(self, ax: Optional[plt.Axes] = None, add_label: bool = True, **patch_ar
213213
fig, ax = plt.subplots(1)
214214

215215
theta = float(self.tilt(mc.NumpyConfig))
216-
e = self.rotate(-theta, mc.NumpyConfig)
216+
e = self.rotate(theta, mc.NumpyConfig)
217217
w = 2 / np.sqrt(float(e.D[0, 0]))
218218
h = 2 / np.sqrt(float(e.D[1, 1]))
219219
c = self.center.astype(float).tolist()

qualtran/rotation_synthesis/math_config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class MathConfig:
4040
_ceil: Callable[[Real], int] = math.ceil
4141
_arctan2: Callable[[Real, Real], Real] = math.atan2
4242
_arcsin: Callable[[Real], Real] = math.asin
43+
_arccos: Callable[[Real], Real] = math.acos
4344
_number: Callable[[Real], Real] = float
4445

4546
def number(self, x) -> Real:
@@ -75,6 +76,9 @@ def __hash__(self) -> int:
7576
def arcsin(self, x: Real) -> Real:
7677
return self._arcsin(x)
7778

79+
def arccos(self, x: Real) -> Real:
80+
return self._arccos(x)
81+
7882

7983
NumpyConfig = MathConfig(
8084
"numpy",
@@ -91,6 +95,7 @@ def arcsin(self, x: Real) -> Real:
9195
lambda x: int(np.ceil(x)),
9296
np.arctan2,
9397
np.arcsin,
98+
np.arccos,
9499
np.float128,
95100
)
96101

@@ -124,5 +129,6 @@ def with_dps(dps: int) -> MathConfig:
124129
lambda x: int(mpmath.ceil(x)),
125130
mpmath.atan2,
126131
mpmath.asin,
132+
mpmath.acos,
127133
mpmath.mpf,
128134
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import numpy as np
16+
17+
import qualtran.rotation_synthesis._typing as rst
18+
import qualtran.rotation_synthesis.math_config as mc
19+
20+
21+
def su_unitary_to_zxz_angles(
22+
u: np.ndarray, config: mc.MathConfig
23+
) -> tuple[rst.Real, rst.Real, rst.Real]:
24+
det = u[0, 0] * u[1, 1] - u[0, 1] * u[1, 0]
25+
# print(det)
26+
# assert config.isclose(det, 1)
27+
28+
cos_phi = (u[1, 1] * u[0, 0] + u[1, 0] * u[0, 1]).real
29+
# clip to the range [-1, 1] to ensure numerical stability.
30+
cos_phi = min(1, max(-1, cos_phi))
31+
phi = config.arccos(cos_phi)
32+
33+
sum_half_theta = config.arctan2(u[1, 1].imag, u[1, 1].real)
34+
diff_half_theta = config.arctan2(u[1, 0].imag, u[1, 0].real) - 1.5 * config.pi
35+
36+
theta1 = sum_half_theta + diff_half_theta
37+
theta2 = sum_half_theta - diff_half_theta
38+
39+
return theta1, phi, theta2
40+
41+
42+
def rx(phi: rst.Real, config: mc.MathConfig) -> np.ndarray:
43+
c = config.cos(phi / 2)
44+
s = config.sin(phi / 2)
45+
return np.array([[c, -1j * s], [-1j * s, c]])
46+
47+
48+
def rz(theta: rst.Real, config: mc.MathConfig) -> np.ndarray:
49+
v = config.cos(theta / 2) + 1j * config.sin(theta / 2)
50+
return np.diag([v.conjugate(), v])
51+
52+
53+
def unitary_from_zxz(
54+
theta1: rst.Real, phi: rst.Real, theta2: rst.Real, config: mc.MathConfig
55+
) -> np.ndarray:
56+
return rz(theta1, config) @ rx(phi, config) @ rz(theta2, config)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import numpy as np
16+
import pytest
17+
from scipy import stats
18+
19+
import qualtran.rotation_synthesis.math_config as mc
20+
from qualtran.rotation_synthesis.matrix import analytical_decomposition as ad
21+
22+
23+
def _random_angles(n, seed):
24+
rng = np.random.default_rng(seed)
25+
for _ in range(n):
26+
yield rng.random(3) * 2 * np.pi
27+
28+
29+
def _random_su2(n, seed):
30+
for u in stats.unitary_group(2).rvs(n, seed):
31+
yield u / np.linalg.det(u) ** 0.5
32+
33+
34+
@pytest.mark.parametrize(["theta1", "phi", "theta2"], _random_angles(100, seed=0))
35+
def test_decomposition_round_trip(theta1, phi, theta2):
36+
u = ad.unitary_from_zxz(theta1, phi, theta2, mc.NumpyConfig)
37+
angles = ad.su_unitary_to_zxz_angles(u, config=mc.NumpyConfig)
38+
v = ad.unitary_from_zxz(*angles, config=mc.NumpyConfig)
39+
np.testing.assert_allclose(actual=u, desired=v)
40+
41+
42+
@pytest.mark.parametrize("u", _random_su2(100, seed=0))
43+
def test_decomposition_round_trip_start_with_unitary(u):
44+
angles = ad.su_unitary_to_zxz_angles(u, config=mc.NumpyConfig)
45+
v = ad.unitary_from_zxz(*angles, config=mc.NumpyConfig)
46+
np.testing.assert_allclose(actual=u, desired=v)

qualtran/rotation_synthesis/matrix/generation_test.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ def test_generated_rotations_determinant():
2828
all_rotations = generate_rotations(5)
2929
for n in range(len(all_rotations)):
3030
assert np.allclose(
31-
[abs(np.linalg.det(r.numpy())) for r in all_rotations[n]], 2 * (2 + np.sqrt(2)) ** n
31+
[abs(np.linalg.det(r.matrix.astype(complex))) for r in all_rotations[n]],
32+
2 * (2 + np.sqrt(2)) ** n,
3233
)
3334

3435

@@ -58,4 +59,4 @@ def test_generated_rotations_unitary():
5859
assert gates is not None
5960
for p in gates:
6061
u = _numpy_matrix_for_symbol[p] @ u
61-
assert _are_close_up_to_global_phase(u, r.numpy())
62+
assert _are_close_up_to_global_phase(u, r.matrix.astype(complex))

qualtran/rotation_synthesis/matrix/su2_ct.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import attrs
2020
import numpy as np
2121

22+
import qualtran.rotation_synthesis.math_config as mc
2223
from qualtran.rotation_synthesis.rings import zsqrt2, zw
2324

2425

@@ -81,8 +82,23 @@ def __hash__(self):
8182
def __eq__(self, other):
8283
return np.all(self.matrix == other.matrix)
8384

84-
def numpy(self) -> np.ndarray:
85-
return self.matrix.astype(complex)
85+
def numpy(self, config: Optional[mc.MathConfig] = None) -> np.ndarray:
86+
"""Returns the numpy representation of the unitary.
87+
Args:
88+
config: An optional MathConfig used to convert the matrix entries to complex
89+
numbers and normalize the result. If not given numpy methods are used.
90+
"""
91+
if config is None:
92+
result = self.matrix.astype(complex)
93+
result = result / np.linalg.det(result) ** 0.5
94+
return result
95+
result = np.zeros((2, 2)) + 1j * config.zero
96+
sqrt_det = config.sqrt(self.det().value(config.sqrt2))
97+
for i in range(2):
98+
for j in range(2):
99+
result[i, j] = self.matrix[i, j].value(config.sqrt2)
100+
result = result / sqrt_det
101+
return result
86102

87103
def adjoint(self) -> "SU2CliffordT":
88104
return SU2CliffordT(self.matrix.T.conj())
@@ -206,16 +222,32 @@ def is_valid(self) -> bool:
206222
_, _, n1 = self.matrix[1, 0].to_zsqrt2()
207223
return n0 == n1
208224

225+
def rescale(self) -> 'SU2CliffordT':
226+
r"""Rescales the matrix such that its determinant is minimized.
209227
210-
_KEY_MAP = {
211-
(0, 0, 0, 0): "Tx",
212-
(0, 0, 1, 0): "Tz",
213-
(0, 1, 0, 0): "Ty",
214-
(0, 1, 1, 0): "Tx",
215-
(1, 0, 1, 0): "Tx",
216-
(1, 1, 0, 0): "Tx",
217-
(1, 1, 1, 0): "Tz",
218-
}
228+
The determinant of the unitary can be written as $2\lambda^n$ where $\lambda=2+\sqrt{l}$
229+
and $n$ is the number of $T$ gates needed to synthesize the matrix. When all entries of
230+
the matrix are divisible by $\lambda$ then we can divide through by $\lambda$ to reduce $n$
231+
"""
232+
u = self
233+
while u.det() > 2 * zsqrt2.LAMBDA_KLIUCHNIKOV:
234+
if not all(a.is_divisible_by(zw.LAMBDA_KLIUCHNIKOV) for a in u.matrix.flat):
235+
break
236+
u = SU2CliffordT(
237+
[[x // zw.LAMBDA_KLIUCHNIKOV for x in row] for row in u.matrix], u.gates
238+
)
239+
return u
240+
241+
def num_t_gates(self) -> int:
242+
"""Returns the number of T gates needed to synthesize the matrix."""
243+
det = self.det()
244+
x = zsqrt2.ZSqrt2(2)
245+
n = 0
246+
while x < det:
247+
x = x * zsqrt2.LAMBDA_KLIUCHNIKOV
248+
n += 1
249+
assert x == det
250+
return n
219251

220252

221253
@functools.cache

qualtran/rotation_synthesis/matrix/su2_ct_test.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import numpy as np
1919
import pytest
2020

21+
import qualtran.rotation_synthesis.math_config as mc
2122
from qualtran.rotation_synthesis.matrix import su2_ct
2223
from qualtran.rotation_synthesis.rings import zsqrt2, zw
2324

@@ -54,9 +55,9 @@ def test_multiply(seq):
5455
for i in seq:
5556
g = g @ _GATES[i]
5657
k += i >= 3 # is a T gate.
57-
g_numpy = (g_numpy @ _GATES[i].numpy()) / _SQRT2
58+
g_numpy = (g_numpy @ _GATES[i].matrix.astype(complex)) / _SQRT2
5859
assert g.det() == 2 * _LAMBDA**k
59-
np.testing.assert_allclose(g.numpy() / _SQRT2, g_numpy, atol=1e-9)
60+
np.testing.assert_allclose(g.matrix.astype(complex) / _SQRT2, g_numpy, atol=1e-9)
6061

6162

6263
@pytest.mark.parametrize("g", _make_random_su(10, 3, random_cliffords=False, seed=0))
@@ -76,9 +77,9 @@ def test_adjoint(g):
7677
["g", "g_numpy"], [[su2_ct.Tx, _TX_numpy], [su2_ct.Ty, _TY_numpy], [su2_ct.Tz, _TZ_numpy]]
7778
)
7879
def test_t_gates(g, g_numpy):
79-
np.testing.assert_allclose(g.numpy() / _SQRT2 / np.sqrt(2 + _SQRT2), g_numpy)
80+
np.testing.assert_allclose(g.matrix.astype(complex) / _SQRT2 / np.sqrt(2 + _SQRT2), g_numpy)
8081
np.testing.assert_allclose(
81-
g.adjoint().numpy() / _SQRT2 / np.sqrt(2 + _SQRT2), g_numpy.T.conjugate()
82+
g.adjoint().matrix.astype(complex) / _SQRT2 / np.sqrt(2 + _SQRT2), g_numpy.T.conjugate()
8283
)
8384

8485

@@ -101,8 +102,33 @@ def test_generate_cliffords():
101102
cirq_cliffords = [
102103
cirq.unitary(c) for c in cirq.SingleQubitCliffordGate.all_single_qubit_cliffords
103104
]
104-
assert np.allclose(np.abs([np.linalg.det(c.numpy()) for c in cliffords]), 2)
105+
assert np.allclose(np.abs([np.linalg.det(c.matrix.astype(complex)) for c in cliffords]), 2)
105106
sqrt2 = np.sqrt(2)
106107
for c in cliffords:
107-
u = c.numpy() / sqrt2
108+
u = c.matrix.astype(complex) / sqrt2
108109
assert np.any([are_close_up_to_global_phase(u, c) for c in cirq_cliffords])
110+
111+
112+
@pytest.mark.parametrize("g", _make_random_su(50, 5, random_cliffords=True, seed=0))
113+
@pytest.mark.parametrize("config", [None, mc.NumpyConfig])
114+
def test_rescale(g: su2_ct.SU2CliffordT, config):
115+
np.testing.assert_allclose(g.numpy(config), g.rescale().numpy(config))
116+
117+
118+
def test_num_t_gates():
119+
for clifford in su2_ct.generate_cliffords():
120+
assert clifford.num_t_gates() == 0
121+
122+
for t in su2_ct.Ts:
123+
assert (clifford @ t).num_t_gates() == 1
124+
125+
for t1 in su2_ct.Ts:
126+
for t2 in su2_ct.Ts:
127+
assert (t1 @ t2).num_t_gates() == 2
128+
129+
for t in su2_ct.Ts:
130+
# still two T gates
131+
assert (t @ t.adjoint()).num_t_gates() == 2
132+
133+
# We need to call .rescale to remove the common factor and reduce the T count.
134+
assert (t @ t.adjoint()).rescale().num_t_gates() == 0

0 commit comments

Comments
 (0)