Skip to content

Commit 1114a96

Browse files
Merge pull request #3 from RQM-Technologies-dev/copilot/add-quaternion-form-theory
Axis-aware S³ compression: emit rx/ry/rz for Cartesian-aligned fused rotations
2 parents 74244b0 + def7677 commit 1114a96

5 files changed

Lines changed: 878 additions & 15 deletions

File tree

src/rqm_optimize/fusion.py

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111

1212
import numpy as np
1313

14-
from .geometry import remove_global_phase
14+
from .geometry import (
15+
quaternion_canonicalize,
16+
quaternion_multiply,
17+
quaternion_to_su2,
18+
remove_global_phase,
19+
su2_to_quaternion,
20+
)
1521
from .qiskit_adapter import extract_matrix, is_fuseable_single_qubit
1622

1723
if TYPE_CHECKING:
@@ -107,24 +113,35 @@ def flush_all() -> None:
107113

108114

109115
def _fuse_matrices(run: list["CircuitInstruction"]) -> np.ndarray:
110-
"""Multiply gate matrices left-to-right (circuit order) and normalise.
116+
"""Accumulate a run of single-qubit gates as quaternion products on S³.
111117
112-
In quantum circuit convention the first gate applied is the leftmost
113-
matrix. We accumulate U_n @ ... @ U_1 by applying right-to-left
114-
composition so that the overall effect is the same as applying gates
115-
sequentially.
118+
Each ``CircuitInstruction`` in *run* is inspected for its 2×2 unitary
119+
matrix, which is then mapped to a unit quaternion via the exact SU(2)
120+
isomorphism. The quaternions are multiplied in circuit order (gate[0]
121+
applied first), and the canonical accumulated result is converted back to
122+
a 2×2 SU(2) matrix.
123+
124+
Using quaternion multiplication instead of raw matrix multiplication keeps
125+
intermediate results on the unit 3-sphere S³, avoids accumulated complex
126+
phase drift, and produces a canonical output via
127+
:func:`~rqm_optimize.geometry.quaternion_canonicalize`.
128+
129+
Circuit order: gate[0] is applied first. Quaternion product ``q_n * … *
130+
q_1`` is accumulated with the last gate on the left (outer position),
131+
matching the matrix convention ``U_n @ … @ U_1``.
116132
117133
Args:
118134
run: List of ``CircuitInstruction`` objects in circuit order.
119135
120136
Returns:
121-
2×2 SU(2)-normalised combined unitary matrix.
137+
2×2 SU(2)-normalized combined unitary matrix.
122138
"""
123-
# Start with identity and compose in circuit order.
124-
# circuit order: gate[0] first, gate[1] second, ...
125-
# combined unitary = gate[-1] @ ... @ gate[1] @ gate[0]
126-
combined = np.eye(2, dtype=complex)
139+
# Identity quaternion: q = (1, 0, 0, 0).
140+
q_accum = np.array([1.0, 0.0, 0.0, 0.0])
127141
for instr in run:
128142
mat = extract_matrix(instr.operation)
129-
combined = mat @ combined
130-
return remove_global_phase(combined)
143+
q_gate = su2_to_quaternion(remove_global_phase(mat))
144+
# Apply q_gate after the current accumulation: q_gate * q_accum.
145+
q_accum = quaternion_multiply(q_gate, q_accum)
146+
q_accum = quaternion_canonicalize(q_accum)
147+
return quaternion_to_su2(q_accum)

src/rqm_optimize/geometry.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,28 @@
22
33
Keeps math helpers small and rigorous. Does not duplicate rqm-core; this
44
module only contains the minimum needed to support circuit-level optimization.
5+
6+
Quaternion convention
7+
---------------------
8+
A unit quaternion is represented as a length-4 NumPy array ``q = [w, x, y, z]``
9+
where *w* is the scalar part and *x, y, z* are the pure-imaginary components.
10+
11+
The exact isomorphism between unit quaternions and SU(2) matrices used here is::
12+
13+
q = (w, x, y, z) <-> U = [[ w - iz, -(y + ix)],
14+
[ y - ix, w + iz ]]
15+
16+
This convention is consistent with the standard quantum rotation gates:
17+
18+
* ``Rx(theta)`` -> ``(cos(theta/2), sin(theta/2), 0, 0 )``
19+
* ``Ry(theta)`` -> ``(cos(theta/2), 0, sin(theta/2), 0 )``
20+
* ``Rz(theta)`` -> ``(cos(theta/2), 0, 0, sin(theta/2))``
21+
22+
Quaternion multiplication ``quaternion_multiply(q2, q1)`` represents applying
23+
*q1* first, then *q2*, matching matrix composition ``U2 @ U1``.
24+
25+
For canonicalization the representative with non-negative scalar part (``w >= 0``)
26+
is preferred, keeping the quaternion on the shortest geodesic arc on S³.
527
"""
628

729
from __future__ import annotations
@@ -69,3 +91,196 @@ def matrices_close(
6991
n = a.shape[0]
7092
inner = np.trace(a.conj().T @ b)
7193
return bool(abs(abs(inner) - n) < atol * n)
94+
95+
96+
# ---------------------------------------------------------------------------
97+
# Quaternion helpers
98+
# ---------------------------------------------------------------------------
99+
100+
def su2_to_quaternion(matrix: NDArray[np.complexfloating]) -> NDArray[np.floating]:
101+
"""Return the unit quaternion ``[w, x, y, z]`` for an SU(2) matrix.
102+
103+
Uses the exact algebraic isomorphism::
104+
105+
U = [[ w - iz, -(y + ix)],
106+
[ y - ix, w + iz ]] <-> q = (w, x, y, z)
107+
108+
The result is normalized and has ``w >= 0`` (canonical shortest-path
109+
representative on S³).
110+
111+
Args:
112+
matrix: 2×2 complex SU(2) array (determinant +1, unitary).
113+
114+
Returns:
115+
Length-4 float array ``[w, x, y, z]`` representing the unit quaternion.
116+
"""
117+
# Extract components from matrix entries:
118+
# U[0,0] = w - iz => w = Re(U[0,0]), z = -Im(U[0,0])
119+
# U[0,1] = -(y+ix) => y = -Re(U[0,1]), x = -Im(U[0,1])
120+
w = float(matrix[0, 0].real)
121+
x = float(-matrix[0, 1].imag)
122+
y = float(-matrix[0, 1].real)
123+
z = float(-matrix[0, 0].imag)
124+
q = np.array([w, x, y, z], dtype=float)
125+
return quaternion_canonicalize(q)
126+
127+
128+
def quaternion_to_su2(q: NDArray[np.floating]) -> NDArray[np.complexfloating]:
129+
"""Return the SU(2) matrix for a unit quaternion ``[w, x, y, z]``.
130+
131+
Inverse of :func:`su2_to_quaternion`. Uses the isomorphism::
132+
133+
q = (w, x, y, z) <-> U = [[ w - iz, -(y + ix)],
134+
[ y - ix, w + iz ]]
135+
136+
Args:
137+
q: Length-4 float array ``[w, x, y, z]``.
138+
139+
Returns:
140+
2×2 complex SU(2) array.
141+
"""
142+
w, x, y, z = float(q[0]), float(q[1]), float(q[2]), float(q[3])
143+
return np.array(
144+
[
145+
[complex(w, -z), complex(-y, -x)],
146+
[complex(y, -x), complex(w, z)],
147+
],
148+
dtype=complex,
149+
)
150+
151+
152+
def quaternion_multiply(
153+
q2: NDArray[np.floating],
154+
q1: NDArray[np.floating],
155+
) -> NDArray[np.floating]:
156+
"""Return the quaternion product ``q2 * q1``.
157+
158+
Represents applying gate *q1* first, then gate *q2*, matching matrix
159+
composition ``U2 @ U1``.
160+
161+
Uses the standard Hamilton product formula::
162+
163+
(w2, x2, y2, z2) * (w1, x1, y1, z1) = (
164+
w2*w1 - x2*x1 - y2*y1 - z2*z1,
165+
w2*x1 + x2*w1 + y2*z1 - z2*y1,
166+
w2*y1 - x2*z1 + y2*w1 + z2*x1,
167+
w2*z1 + x2*y1 - y2*x1 + z2*w1,
168+
)
169+
170+
Args:
171+
q2: Unit quaternion for the second (outer) gate.
172+
q1: Unit quaternion for the first (inner) gate.
173+
174+
Returns:
175+
Product quaternion (not yet canonicalized or re-normalized).
176+
"""
177+
w2, x2, y2, z2 = float(q2[0]), float(q2[1]), float(q2[2]), float(q2[3])
178+
w1, x1, y1, z1 = float(q1[0]), float(q1[1]), float(q1[2]), float(q1[3])
179+
return np.array(
180+
[
181+
w2 * w1 - x2 * x1 - y2 * y1 - z2 * z1,
182+
w2 * x1 + x2 * w1 + y2 * z1 - z2 * y1,
183+
w2 * y1 - x2 * z1 + y2 * w1 + z2 * x1,
184+
w2 * z1 + x2 * y1 - y2 * x1 + z2 * w1,
185+
],
186+
dtype=float,
187+
)
188+
189+
190+
def quaternion_canonicalize(q: NDArray[np.floating]) -> NDArray[np.floating]:
191+
"""Return the canonical unit quaternion with non-negative scalar part.
192+
193+
Both *q* and *-q* represent the same physical SU(2) rotation. This
194+
function normalizes the quaternion and selects the representative with
195+
``w >= 0``, keeping it on the shortest geodesic arc (canonical
196+
representative) on S³.
197+
198+
Args:
199+
q: Length-4 float array ``[w, x, y, z]``.
200+
201+
Returns:
202+
Normalized quaternion with ``w >= 0``.
203+
"""
204+
norm = float(np.linalg.norm(q))
205+
if norm < _ATOL:
206+
return np.array([1.0, 0.0, 0.0, 0.0], dtype=float)
207+
q = q / norm
208+
if q[0] < 0.0:
209+
q = -q
210+
return q
211+
212+
213+
def quaternion_to_axis_angle(
214+
q: NDArray[np.floating],
215+
) -> tuple[NDArray[np.floating], float]:
216+
"""Extract the rotation axis and angle from a unit quaternion.
217+
218+
The physical Bloch-sphere rotation angle is ``theta = 2 * arccos(w)``
219+
(the factor of 2 arises from the half-angle spinor convention).
220+
221+
Args:
222+
q: Length-4 float array ``[w, x, y, z]``, assumed to be a unit
223+
quaternion. Canonicalization is applied internally.
224+
225+
Returns:
226+
A tuple ``(axis, theta)`` where:
227+
228+
* ``axis`` is a length-3 float array giving the unit rotation axis
229+
``[nx, ny, nz]``. When the rotation is near-identity (``theta``
230+
close to 0), ``axis`` defaults to ``[0, 0, 1]``.
231+
* ``theta`` is the rotation angle in radians (in ``[0, pi]`` after
232+
canonicalization).
233+
"""
234+
q = quaternion_canonicalize(q)
235+
w = float(np.clip(q[0], -1.0, 1.0))
236+
theta = 2.0 * float(np.arccos(w))
237+
sin_half = float(np.sqrt(max(0.0, 1.0 - w * w)))
238+
if sin_half < _ATOL:
239+
axis = np.array([0.0, 0.0, 1.0], dtype=float)
240+
else:
241+
axis = np.array(q[1:], dtype=float) / sin_half
242+
return axis, theta
243+
244+
245+
# Reference unit vectors for the three Cartesian rotation axes.
246+
_CARTESIAN_AXES: tuple[tuple[str, NDArray[np.floating]], ...] = (
247+
("x", np.array([1.0, 0.0, 0.0])),
248+
("y", np.array([0.0, 1.0, 0.0])),
249+
("z", np.array([0.0, 0.0, 1.0])),
250+
)
251+
252+
# Default tolerance for axis-alignment checks.
253+
_AXIS_ATOL = 1e-6
254+
255+
256+
def axis_aligned_rotation(
257+
q: NDArray[np.floating],
258+
atol: float = _AXIS_ATOL,
259+
) -> tuple[str, float] | None:
260+
"""Return ``(axis_name, theta)`` if *q* is a rotation about x, y, or z.
261+
262+
Checks whether the rotation axis of *q* lies within *atol* of the x, y, or
263+
z unit vector. This is the geometric test that drives the axis-aware
264+
compression pass: when the result is non-``None``, the caller can emit a
265+
single named rotation gate (``rx``, ``ry``, or ``rz``) instead of a
266+
generic ``U`` gate.
267+
268+
Near-identity rotations (``|theta| < atol``) return ``None`` because the
269+
axis is numerically undefined at that scale.
270+
271+
Args:
272+
q: Length-4 float array ``[w, x, y, z]``, a unit quaternion.
273+
atol: Absolute tolerance for axis alignment and near-identity detection.
274+
275+
Returns:
276+
``("x"/"y"/"z", theta)`` when the axis is Cartesian-aligned, or
277+
``None`` when the rotation is generic or near-identity.
278+
"""
279+
q = quaternion_canonicalize(q)
280+
axis, theta = quaternion_to_axis_angle(q)
281+
if abs(theta) < atol:
282+
return None
283+
for name, ref in _CARTESIAN_AXES:
284+
if np.allclose(axis, ref, atol=atol):
285+
return name, theta
286+
return None

src/rqm_optimize/qiskit_adapter.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,48 @@ def _try_extract_matrix(op: "Instruction") -> np.ndarray | None:
178178
return None
179179

180180

181+
def emit_axis_aligned_gate(
182+
matrix: np.ndarray,
183+
circuit: "QuantumCircuit",
184+
qubit: "Qubit",
185+
) -> int:
186+
"""Emit a single Rx/Ry/Rz gate if *matrix* is a Cartesian-axis rotation.
187+
188+
Inspects the quaternion representation of *matrix* to determine whether the
189+
fused rotation is aligned with the x, y, or z axis on S³. When it is, a
190+
single named rotation gate (``rx``, ``ry``, or ``rz``) is appended to
191+
*circuit* and 1 is returned. When the rotation is generic or near-identity,
192+
the circuit is left unchanged and 0 is returned.
193+
194+
This is the axis-aware compression pass: it operates directly on the S³
195+
quaternion representation rather than on matrices, and produces a semantically
196+
named gate that is directly executable on hardware (in particular, ``rz`` is
197+
virtual/"free" on many backends).
198+
199+
Args:
200+
matrix: 2×2 SU(2)-normalized unitary matrix for the fused run.
201+
circuit: Target ``QuantumCircuit`` to append the gate to.
202+
qubit: The target qubit.
203+
204+
Returns:
205+
1 if an axis-aligned gate was emitted, 0 otherwise.
206+
"""
207+
from .geometry import axis_aligned_rotation, su2_to_quaternion
208+
209+
q = su2_to_quaternion(matrix)
210+
result = axis_aligned_rotation(q)
211+
if result is None:
212+
return 0
213+
axis_name, theta = result
214+
if axis_name == "x":
215+
circuit.rx(theta, qubit)
216+
elif axis_name == "y":
217+
circuit.ry(theta, qubit)
218+
else:
219+
circuit.rz(theta, qubit)
220+
return 1
221+
222+
181223
def emit_euler_gate(
182224
unitary: np.ndarray,
183225
circuit: "QuantumCircuit",
@@ -190,7 +232,7 @@ def emit_euler_gate(
190232
produce a compact, exact decomposition.
191233
192234
Args:
193-
unitary: 2×2 complex SU(2)-normalised unitary matrix.
235+
unitary: 2×2 complex SU(2)-normalized unitary matrix.
194236
circuit: The ``QuantumCircuit`` to append gates to.
195237
qubit: The target qubit.
196238
basis: Euler decomposer basis string (e.g. ``"U"``, ``"ZSX"``,
@@ -246,9 +288,19 @@ def build_optimized_circuit(
246288
matrix: np.ndarray = seg["matrix"]
247289
original_count: int = seg["original_count"]
248290

249-
# Build a temporary single-qubit sub-circuit to count decomposition.
250291
from qiskit import QuantumCircuit as QC
251292

293+
# First pass: axis-aware compression.
294+
# If the fused rotation is aligned with the x, y, or z axis, emit
295+
# a single rx/ry/rz gate directly from the S³ quaternion form.
296+
# This always produces 1 gate (≤ original_count which is ≥ 2).
297+
tmp = QC(1)
298+
if emit_axis_aligned_gate(matrix, tmp, tmp.qubits[0]) > 0:
299+
for instr in tmp.data:
300+
out.append(instr.operation, [qubit])
301+
continue
302+
303+
# Second pass: Euler decomposer for generic (non-axis-aligned) rotations.
252304
tmp = QC(1)
253305
emitted = emit_euler_gate(matrix, tmp, tmp.qubits[0], basis=basis)
254306

0 commit comments

Comments
 (0)