---
jupytext:
  formats: ipynb,md:myst
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.14.0
kernelspec:
  display_name: Python 3 (phys-555)
  language: python
  name: phys-555
---

# Universal Quantum Computing II

```{code-cell} ipython3
:tags: [hide-cell]

import mmf_setup; mmf_setup.nbinit()
```

## Two-Qubit Operations

In the previous assignment, you learned how to implement arbitrary single-qubit
operations by controlling magnetic fields.  A universal set of gates can be constructed
out of these and a single two-qubit operation such as the ᴄɴᴏᴛ gate:

\begin{gather*}
  \mathsf{\small CNOT} = 
  \begin{pmatrix}
    1 & 0 & 0 & 0\\
    0 & 1 & 0 & 0\\
    0 & 0 & 0 & 1\\
    0 & 0 & 1 & 0
  \end{pmatrix}.
\end{gather*}

Implementing this gate is significantly more challenging.  Here we will explore some
possible ways of doing this.

Continuing with out example spin-½ qubits, consider the dipole-dipole interaction:

\begin{gather*}
  \op{V} = \mu_{D} \vect{\op{S}}_{A} \cdot \vect{\op{S}}_{B}
         = \mu_{D}\begin{pmatrix}
           1\\
           & -1 & 2\\
           & 2 & -1\\
           &&& 1
         \end{pmatrix}.
\end{gather*}

This interaction describes the behavior of one qubit in the presence of the magnetic
field generated by the spin of the other qubit and vice-versa.  To simplify our results,
we shall choose units so that $\hbar = \mu_D = 1$.

:::{margin}
\begin{gather*}
     \begin{pmatrix}
       \mat{σ}_x\\
       & \mat{σ}_x
     \end{pmatrix}
     \begin{pmatrix}
       & \mat{1}\\
       \mat{1}
     \end{pmatrix} + \\
     \begin{pmatrix}
       \mat{σ}_y\\
       & \mat{σ}_y
     \end{pmatrix}
     \begin{pmatrix}
       & -\I\mat{1}\\
       \I\mat{1}
     \end{pmatrix} + \\
     \begin{pmatrix}
       \mat{σ}_z\\
       & \mat{σ}_z
     \end{pmatrix}
     \begin{pmatrix}
       \mat{1}\\
       & - \mat{1}
     \end{pmatrix}\\
   = \begin{pmatrix}
       & & & 1\\
       & & 1 \\
       & 1\\
       1\\
     \end{pmatrix}\\
     +\begin{pmatrix}
       &&& -1\\
       && 1\\
       & 1\\
       -1\\
     \end{pmatrix}\\
     +\begin{pmatrix}
       1\\
       & -1 \\
       && -1\\
       && & 1\\
     \end{pmatrix}
   \end{gather*}
:::
1. Show that, in these simplified units, 

   \begin{gather*}
     \op{V} = \frac{1}{4}
         \begin{pmatrix}
           1\\
           & -1 & 2\\
           & 2 & -1\\
           &&& 1
         \end{pmatrix}
   \end{gather*}
   
   by explicitly computing this from the Pauli matrices:
   
   \begin{gather*}
     \vec{\op{S}_{A}} = \frac{\vec{\mat{σ}}}{2}\otimes \mat{1}, \qquad
     \vec{\op{S}_{B}} = \mat{1}\otimes \frac{\vec{\mat{σ}}}{2}.
   \end{gather*}
   
   I.e. show that

   \begin{gather*}
     4\op{V} = 
     \sum_{i=\{x, y, z\}}
     (\mat{σ}_i\otimes \mat{1})(\mat{1}\otimes \mat{σ}_i)\\
     =
     \begin{pmatrix}
       0 & 1\\
       1 & 0 \\
       && 0 & 1\\
       && 1 & 0\\
     \end{pmatrix}
     \begin{pmatrix}
       && 1 & 0\\
       && 0 & 1 \\
       1 & 0\\
       0 & 1\\
     \end{pmatrix} + \cdots\\
     =
     \begin{pmatrix}
        1\\
        & -1 & 2\\
        & 2& -1\\
        &&& 1
     \end{pmatrix}
   \end{gather*}

2. Diagonalize this matrix so that you can compute the matrix exponential and find the
   form of the corresponding 2-qubit unitary operations.  *(Hint: you only need to
   diagonalize a 2×2 block, and this block is diagonalized by the matrix corresponding
   to the Hadamard gate.)*

   :::{dropdown} Answer
   
   The answer can be expressed in terms of the Hadamard gate $H$:
   \begin{gather*}
     \begin{pmatrix}
        -1 & 2\\
        2& -1
     \end{pmatrix}
     =
     H
     \begin{pmatrix}
        1\\
        & -3
     \end{pmatrix}
     H
   \end{gather*}
   :::
   
   I.e. show that 
   
   \begin{gather*}
     \mat{U} = \exp\left(\mat{V}t/\I\hbar\right)
     =
     \begin{pmatrix}
        e^{\I\theta}\\
        & H\begin{pmatrix}
            e^{\I\theta}\\
            & e^{-3\I\theta}
          \end{pmatrix}
          H\\
        && e^{\I\theta}
     \end{pmatrix},
   \end{gather*}
   
   where $\theta = \hbar \mu_D t/4$.

3. Can you combine this with single qubit gates to form ᴄɴᴏᴛ?



```python
# Brute-force optimization to see if we can do this.
# Apparently not.
import mmf_setup; mmf_setup.nbinit()
import numpy as np
import scipy.linalg
from scipy.linalg import expm, logm, eigh
from scipy.optimize import minimize
from numpy import kron
from phys_555_2022.utils import sigmas

H = np.array([[1, 1], [1, -1]]) / np.sqrt(2)
X, Y, Z = sigmas
CNOT = np.eye(4)
CNOT[2:, 2:] = X.real
CZ = np.eye(4)
CZ[2:, 2:] = Z.real

V = np.diag([1, -1, -1, 1])
V[1,2] = V[2, 1] = 2

I = np.eye(2)
sigma_mus = np.asarray([I] + sigmas.tolist())

def unpack(y):
    theta = y[-1]
    bs = np.reshape(y[:-1], (4, 4))
    Hs = [np.einsum('a,a...', _b, sigma_mus) for _b in bs]
    Us = [expm(_H/2j) for _H in Hs]
    return Us, theta
    

def get_residue(y):
    (Ui1, Ui2, Uo1, Uo2), theta = unpack(y)
    M = np.zeros((4, 4), dtype=complex)
    M[1:3, 1:3] = H @ np.diag([np.exp(1j*theta), np.exp(-3j*theta)]) @ H
    M[0, 0] = M[-1, -1] = np.exp(1j*theta)
    U = kron(Ui1, Ui2) @ M @ kron(Uo1, Uo2)
    #U *= np.exp(-1j*np.angle(U[0,0]))
    assert np.allclose(U.conj().T @ U, np.eye(4))
    return (abs(U - CNOT)**2).sum()


def get_res(rng=np.random.default_rng(seed=1)):
    y0 = rng.random(17) * 2*np.pi
    res = minimize(get_residue, x0=y0)
    return res.fun


def find_match(U_goal, H, rng=np.random.default_rng(seed=1)):
    """Try to find a set of 4 single-qubit gates transforming U to U_goal.
    
    Arguments
    ---------
    U_goal : 4x4 array
        Target 2-qubit gate.
    H : 4x4 array
        Starting 2-qubit Hamiltonian.  Single qubit operations will be applied to
        the inputs and outputs to try to match U_goal with exponentiation of H.
    """
    assert np.allclose(H, H.T.conj())
    D, V = eigh(H)
    assert np.allclose(V @ np.diag(D) @ V.T.conj(), H)
    
    def unpack(y):
        theta = y[-1]
        bs = np.reshape(y[:-1], (4, 4))
        Hs = [np.einsum('a,a...', _b, sigma_mus) for _b in bs]
        Us = [expm(_H/2j) for _H in Hs]
        return Us, theta 

    def get_U(y):
        (Ui1, Ui2, Uo1, Uo2), theta = unpack(y)
        M = V @ np.diag(np.exp(1j*theta*D)) @ V.T.conj()
        U = kron(Ui1, Ui2) @ M @ kron(Uo1, Uo2)
        assert np.allclose(U.conj().T @ U, np.eye(4))
        return U

    def get_residue(y):
        return (abs(get_U(y) - U_goal)**2).sum()
        
    y0 = rng.random(17) * 2*np.pi
    res = minimize(get_residue, x0=y0)
    U = get_U(res.x)
    H = logm(U)/1j
    return res.fun, U, H
```

```{code-cell} ipython3
import numpy as np
import scipy.linalg
from scipy.linalg import expm, logm, eigh
from scipy.optimize import minimize
from numpy import kron
from phys_555_2022.utils import sigmas

H = np.array([[1, 1], [1, -1]]) / np.sqrt(2)
X, Y, Z = sigmas
CNOT = np.eye(4)
CNOT[2:, 2:] = X.real
CZ = np.eye(4)
CZ[2:, 2:] = Z.real

I = np.eye(2)
sigma_mus = np.asarray([I] + sigmas.tolist())

def find_match(U_goal, H, y0=None, rng=np.random.default_rng(seed=1)):
    """Try to find a set of 4 single-qubit gates transforming U to U_goal.

    Arguments
    ---------
    U_goal : 4x4 array
        Target 2-qubit gate.
    H : 4x4 array
        Starting 2-qubit Hamiltonian.  Single qubit operations will be applied to
        the inputs and outputs to try to match U_goal with exponentiation of H.
    """
    assert np.allclose(H, H.T.conj())
    D, V = eigh(H)
    assert np.allclose(V @ np.diag(D) @ V.T.conj(), H)

    def unpack(y):
        theta = y[-1]
        bs = np.reshape(y[:-1], (4, 4))
        Hs = [np.einsum('a,a...', _b, sigma_mus) for _b in bs]
        Us = [expm(_H/2j) for _H in Hs]
        return Us, theta 

    def get_U(y):
        (Ui1, Ui2, Uo1, Uo2), theta = unpack(y)
        M = V @ np.diag(np.exp(1j*theta*D)) @ V.T.conj()
        U = kron(Ui1, Ui2) @ M @ kron(Uo1, Uo2)
        assert np.allclose(U.conj().T @ U, np.eye(4))
        return U

    def get_residue(y):
        return (abs(get_U(y) - U_goal)**2).sum()

    if y0 is None:
        y0 = rng.random(17) * 2*np.pi
    res = minimize(get_residue, x0=y0)
    U = get_U(res.x)
    H = logm(U)/1j
    return res, U, H
```

```{code-cell} ipython3
rng = np.random.default_rng(seed=3)
V = rng.normal(size=(4, 4*2)).view(complex)
V += V.T.conj()
V = V.reshape((2, 2, 2, 2))
V = (V + np.einsum('abcd->badc', V)).reshape((4, 4))

V = np.diag([1, -1, -1, 1])
V[1,2] = V[2, 1] = 2

res, U, UH = find_match(CNOT, V, rng=rng)

for n in range(10):
    res, U, UH = find_match(CNOT, UH, rng=rng)
    print(res.fun)
#res, U, UH = find_match(CNOT, UH, y0=res.x, rng=rng)
#print(res.fun)
#for n in range(10):
#    res, U, UH = find_match(CNOT, UH, y0=0*res.x, rng=rng)
#    print(res.fun)
```

```{code-cell} ipython3
(abs(U - CNOT)**2).sum()
```

```{code-cell} ipython3

```
