Skip to main content

ppvm_pauli_sum/sum/
rot2.rs

1// SPDX-FileCopyrightText: 2026 The PPVM Authors
2// SPDX-License-Identifier: Apache-2.0
3
4use super::rot1::rotate_1_map_insert_closure;
5use crate::sum::PauliSum;
6use ppvm_traits::char::Pauli;
7use ppvm_traits::config::Config;
8use ppvm_traits::traits::*;
9
10const PAULIS: [Pauli; 4] = [Pauli::I, Pauli::X, Pauli::Z, Pauli::Y];
11
12impl<T: Config> RotationTwo<T> for PauliSum<T>
13where
14    T::Coeff: std::ops::MulAssign,
15    T::Map: ACMapInsert<T::Storage, T::Coeff, T::BuildHasher, T::PauliWordType> + ACMapConsume,
16{
17    fn rotate_2(&mut self, axis_a: [u8; 2], axis_b: [u8; 2], a: usize, b: usize, theta: T::Coeff) {
18        let [axis_a_x, axis_a_z] = axis_a;
19        let [axis_b_x, axis_b_z] = axis_b;
20        if axis_a_x > 3 || axis_a_z > 3 || axis_b_x > 3 || axis_b_z > 3 {
21            panic!("Rotation axis cannot be L");
22        }
23        let (sin, cos) = theta.sin_cos();
24        let pauli_a = PAULIS[(axis_a_z << 1 | axis_a_x) as usize];
25        let pauli_b = PAULIS[(axis_b_z << 1 | axis_b_x) as usize];
26        self.map_insert(|k, v| {
27            // NOTE: case of both qubits being lost is handled by single-qubit rotation logic
28            if k.get_lbit(a) {
29                // fall back to single-qubit rotation on qubit b
30                return rotate_1_map_insert_closure::<T>(k, v, pauli_b, b, &sin, &cos);
31            }
32            if k.get_lbit(b) {
33                // fall back to single-qubit rotation on qubit a
34                return rotate_1_map_insert_closure::<T>(k, v, pauli_a, a, &sin, &cos);
35            }
36            let (eps, x_a, z_a, x_b, z_b) = comm_2(
37                axis_a,
38                axis_b,
39                [k.get_xbit(a) as u8, k.get_zbit(a) as u8],
40                [k.get_xbit(b) as u8, k.get_zbit(b) as u8],
41            );
42
43            if eps == 0 {
44                None
45            } else {
46                let mut coeff = v.clone();
47                *v *= cos.clone();
48
49                let mut new_word = k.clone();
50                new_word.set_xbit(a, x_a == 1);
51                new_word.set_xbit(b, x_b == 1);
52                new_word.set_zbit(a, z_a == 1);
53                new_word.set_zbit(b, z_b == 1);
54                new_word.rehash();
55
56                coeff *= sin.mul_sign(-eps);
57                Some((new_word, coeff))
58            }
59        });
60    }
61
62    #[inline]
63    fn rzz(&mut self, a: usize, b: usize, theta: impl Into<T::Coeff>) {
64        let (sin, cos) = theta.into().sin_cos();
65        self.map_insert(|k, v| {
66            // Loss fallbacks — identical to the generic `rotate_2` path
67            // (axis on the surviving qubit is Z for a ZZ rotation). These
68            // branches are dead-code-eliminated for non-lossy PauliWord,
69            // since `get_lbit` is a const `false`.
70            if k.get_lbit(a) {
71                return rotate_1_map_insert_closure::<T>(k, v, Pauli::Z, b, &sin, &cos);
72            }
73            if k.get_lbit(b) {
74                return rotate_1_map_insert_closure::<T>(k, v, Pauli::Z, a, &sin, &cos);
75            }
76            let xa = k.get_xbit(a);
77            let xb = k.get_xbit(b);
78            // ZZ commutes iff both qubits agree on having an X-component.
79            if xa == xb {
80                return None;
81            }
82            let za = k.get_zbit(a);
83            let zb = k.get_zbit(b);
84            // The anticommuting qubit is the one with xbit == 1.
85            // sign = +1 if it is Y (zbit set), -1 if it is X (zbit clear).
86            let z_anti = if xa { za } else { zb };
87            let eps: i8 = if z_anti { 1 } else { -1 };
88
89            let mut coeff = v.clone();
90            *v *= cos.clone();
91
92            let mut new_word = k.clone();
93            new_word.set_zbit(a, !za);
94            new_word.set_zbit(b, !zb);
95            new_word.rehash();
96
97            coeff *= sin.mul_sign(eps);
98            Some((new_word, coeff))
99        });
100    }
101
102    #[inline]
103    fn rxx(&mut self, a: usize, b: usize, theta: impl Into<T::Coeff>) {
104        let (sin, cos) = theta.into().sin_cos();
105        self.map_insert(|k, v| {
106            // Loss fallback: a lost qubit leaves a single-qubit X rotation on
107            // the surviving partner (axis on the surviving qubit is X for an
108            // XX rotation), matching the generic `rotate_2` path.
109            if k.get_lbit(a) {
110                return rotate_1_map_insert_closure::<T>(k, v, Pauli::X, b, &sin, &cos);
111            }
112            if k.get_lbit(b) {
113                return rotate_1_map_insert_closure::<T>(k, v, Pauli::X, a, &sin, &cos);
114            }
115            let za = k.get_zbit(a);
116            let zb = k.get_zbit(b);
117            // X anticommutes with a qubit's Pauli iff that Pauli carries a
118            // Z-component (Z or Y). XX commutes iff both qubits agree on that.
119            if za == zb {
120                return None;
121            }
122            let xa = k.get_xbit(a);
123            let xb = k.get_xbit(b);
124            // The anticommuting qubit is the one carrying Z (its z-bit set).
125            // sign = +1 if it is Z (x-bit clear), -1 if it is Y (x-bit set).
126            let x_anti = if za { xa } else { xb };
127            let eps: i8 = if x_anti { -1 } else { 1 };
128
129            let mut coeff = v.clone();
130            *v *= cos.clone();
131
132            let mut new_word = k.clone();
133            new_word.set_xbit(a, !xa);
134            new_word.set_xbit(b, !xb);
135            new_word.rehash();
136
137            coeff *= sin.mul_sign(eps);
138            Some((new_word, coeff))
139        });
140    }
141
142    #[inline]
143    fn ryy(&mut self, a: usize, b: usize, theta: impl Into<T::Coeff>) {
144        let (sin, cos) = theta.into().sin_cos();
145        self.map_insert(|k, v| {
146            // Loss fallback: a lost qubit leaves a single-qubit Y rotation on
147            // the surviving partner (axis on the surviving qubit is Y for a
148            // YY rotation), matching the generic `rotate_2` path.
149            if k.get_lbit(a) {
150                return rotate_1_map_insert_closure::<T>(k, v, Pauli::Y, b, &sin, &cos);
151            }
152            if k.get_lbit(b) {
153                return rotate_1_map_insert_closure::<T>(k, v, Pauli::Y, a, &sin, &cos);
154            }
155            let xa = k.get_xbit(a);
156            let za = k.get_zbit(a);
157            let xb = k.get_xbit(b);
158            let zb = k.get_zbit(b);
159            // Y anticommutes with a qubit's Pauli iff it is X or Z, i.e. iff
160            // its x-bit and z-bit differ. YY commutes iff both qubits agree.
161            let pa = xa ^ za;
162            let pb = xb ^ zb;
163            if pa == pb {
164                return None;
165            }
166            // The anticommuting qubit is X or Z (x-bit != z-bit).
167            // sign = +1 if it is X (x-bit set), -1 if it is Z (x-bit clear).
168            let x_anti = if pa { xa } else { xb };
169            let eps: i8 = if x_anti { 1 } else { -1 };
170
171            let mut coeff = v.clone();
172            *v *= cos.clone();
173
174            let mut new_word = k.clone();
175            new_word.set_xbit(a, !xa);
176            new_word.set_zbit(a, !za);
177            new_word.set_xbit(b, !xb);
178            new_word.set_zbit(b, !zb);
179            new_word.rehash();
180
181            coeff *= sin.mul_sign(eps);
182            Some((new_word, coeff))
183        });
184    }
185}
186
187// R_{G}[P] = cos(theta) P - sin(theta) [G, P]/2i
188// Encoding for one qubit
189//   x = 0/1  (X flag)   z = 0/1  (Z flag)
190//   (x,z) = 00 → I , 10 → X , 01 → Z , 11 → Y   (little-endian)
191//
192// Two–qubit word Q = q0 ⊗ q1  is given as four bits
193//   (x_a , z_a)  … qubit-0 of Q
194//   (x_b , z_b)  … qubit-1 of Q
195// P = p0 ⊗ p1   likewise
196//   (x_c , z_c)  … qubit-0 of P
197//   (x_d , z_d)  … qubit-1 of P
198//
199// The function returns
200//   (coeff , xx_out , zz_out)
201// where  coeff ∈ {-1,0,+1}  and  (xx_out, zz_out) are the usual packed masks
202// (bit-0 = qubit-0, bit-1 = qubit-1).
203/// Branch-free commutator  [ Q , P ] / (2 i )
204///
205///   Each qubit is encoded as `[x, z]` bits (0 or 1):
206///       `q0 = [x_a, z_a]`  Q qubit-0       `q1 = [x_b, z_b]`  Q qubit-1
207///       `p0 = [x_c, z_c]`  P qubit-0       `p1 = [x_d, z_d]`  P qubit-1
208///
209///   Returns (coeff , x_out0 , z_out0 , x_out1 , z_out1)
210///           coeff ∈ { -1 , 0 , +1 }
211#[inline(always)]
212pub fn comm_2(q0: [u8; 2], q1: [u8; 2], p0: [u8; 2], p1: [u8; 2]) -> (i8, u8, u8, u8, u8) {
213    let [x_a, z_a] = q0;
214    let [x_b, z_b] = q1;
215    let [x_c, z_c] = p0;
216    let [x_d, z_d] = p1;
217    // ── 1.  per-qubit anticommutation bits  a₀ , a₁  ───────────────────────
218    let a0 = (x_a & z_c) ^ (z_a & x_c); // qubit-0 anticommutes?
219    let a1 = (x_b & z_d) ^ (z_b & x_d); // qubit-1 anticommutes?
220
221    // overall commutator present when exactly one qubit anticommutes
222    let present = a0 ^ a1; // 0 ↔ commute, 1 ↔ anticommute
223
224    // ── 2.  sign of the coefficient (+1 / −1)  ─────────────────────────────
225    // 16-entry bit-mask: 1 → “negative orientation” ( −1 ), 0 → “positive” (+1)
226    const SIGN_NEG: u16 = 0x2840; // pre-computed once
227
228    let idx0 = (z_a << 3) | (x_a << 2) | (z_c << 1) | x_c; // qubit-0 pair index 0-15
229    let idx1 = (z_b << 3) | (x_b << 2) | (z_d << 1) | x_d; // qubit-1 pair index
230
231    let neg0 = (((SIGN_NEG >> idx0) as u8) & 1) & a0; // only meaningful if a₀ = 1
232    let neg1 = (((SIGN_NEG >> idx1) as u8) & 1) & a1;
233
234    // coeff = ( +1 or −1 ) from the unique acted-on qubit; 0 if they commute
235    let coeff = (((1 - ((neg0 as i8) << 1)) * (a0 as i8))
236        + ((1 - ((neg1 as i8) << 1)) * (a1 as i8)))
237        * (present as i8); // ensures 0 when commuting
238
239    // ── 3.  output flags  (product of Q and P)  masked to zero if commuting ─
240    let x_out0 = (x_a ^ x_c) & present;
241    let z_out0 = (z_a ^ z_c) & present;
242    let x_out1 = (x_b ^ x_d) & present;
243    let z_out1 = (z_b ^ z_d) & present;
244
245    (coeff, x_out0, z_out0, x_out1, z_out1)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::config::fxhash::ByteF64;
252
253    type C = ByteF64<2>;
254    const PAULI_CHARS: [char; 4] = ['I', 'X', 'Y', 'Z'];
255
256    fn sum_with(word: &str) -> PauliSum<C> {
257        let mut s: PauliSum<C> = PauliSum::builder().n_qubits(2).build();
258        s += (word, 1.0);
259        s
260    }
261
262    /// Every diagonal two-qubit rotation has a hand-written fast path in this
263    /// file that bypasses `comm_2`. Assert it produces exactly the same
264    /// `PauliSum` as the generic `rotate_2` (the pre-existing `comm_2`-based
265    /// path) for every two-qubit input Pauli, at a representative angle.
266    fn assert_matches_generic(
267        axis: [u8; 2],
268        special: impl Fn(&mut PauliSum<C>, usize, usize, f64),
269    ) {
270        let theta = 0.7_f64;
271        for &p in &PAULI_CHARS {
272            for &q in &PAULI_CHARS {
273                let word = format!("{p}{q}");
274
275                let mut got = sum_with(&word);
276                special(&mut got, 0, 1, theta);
277
278                let mut want = sum_with(&word);
279                want.rotate_2(axis, axis, 0, 1, theta);
280
281                assert_eq!(got, want, "fast path disagrees with rotate_2 for {word}");
282            }
283        }
284    }
285
286    #[test]
287    fn rxx_matches_generic() {
288        assert_matches_generic([1, 0], |s, a, b, t| s.rxx(a, b, t));
289    }
290
291    #[test]
292    fn ryy_matches_generic() {
293        assert_matches_generic([1, 1], |s, a, b, t| s.ryy(a, b, t));
294    }
295
296    #[test]
297    fn rzz_matches_generic() {
298        assert_matches_generic([0, 1], |s, a, b, t| s.rzz(a, b, t));
299    }
300
301    /// Explicit hand-computed values, independent of `rotate_2`/`comm_2`, so a
302    /// bug shared by both the fast path and the generic path can't hide.
303    #[test]
304    fn rzz_explicit_values() {
305        let t = 0.7_f64;
306
307        // X_0 I_1 --rzz--> cos·XI − sin·YZ  (anticommutes; X-carrier → −1)
308        let mut s = sum_with("XI");
309        s.rzz(0, 1, t);
310        let mut want: PauliSum<C> = PauliSum::builder().n_qubits(2).build();
311        want += ("XI", t.cos());
312        want += ("YZ", -t.sin());
313        assert_eq!(s, want);
314
315        // Y_0 I_1 --rzz--> cos·YI + sin·XZ  (anticommutes; Y-carrier → +1)
316        let mut s = sum_with("YI");
317        s.rzz(0, 1, t);
318        let mut want: PauliSum<C> = PauliSum::builder().n_qubits(2).build();
319        want += ("YI", t.cos());
320        want += ("XZ", t.sin());
321        assert_eq!(s, want);
322
323        // ZZ commutes with the ZZ generator → unchanged.
324        let mut s = sum_with("ZZ");
325        s.rzz(0, 1, t);
326        assert_eq!(s, sum_with("ZZ"));
327    }
328
329    /// A diagonal rotation on non-adjacent qubits must address the right
330    /// slots (exercises `a`/`b` other than `0,1`).
331    #[test]
332    fn rzz_non_adjacent_addressing() {
333        let theta = 0.7_f64;
334        for &p in &PAULI_CHARS {
335            for &q in &PAULI_CHARS {
336                // 3-qubit word with the acted-on Paulis on qubits 0 and 2.
337                let word = format!("{p}I{q}");
338
339                let mut got: PauliSum<C> = PauliSum::builder().n_qubits(3).build();
340                got += (word.as_str(), 1.0);
341                got.rzz(0, 2, theta);
342
343                let mut want: PauliSum<C> = PauliSum::builder().n_qubits(3).build();
344                want += (word.as_str(), 1.0);
345                want.rotate_2([0, 1], [0, 1], 0, 2, theta);
346
347                assert_eq!(got, want, "rzz(0,2) disagrees with rotate_2 for {word}");
348            }
349        }
350    }
351}