src/qsim/gate.py
0a4b3d01
 """Quantum gate operations
346cf14e
 
0a4b3d01
 A quantum gate acting on :math:`n` qubits is a :math:`2^n×2^n` unitary
 matrix written in the computational basis.
 """
 
 import numpy as np
 
 __all__ = [
1673b8dc
     "apply",
0a4b3d01
     "n_qubits",
     "controlled",
     # -- Single qubit gates --
     "id",
     "x",
     "y",
     "z",
     "not_",
     "sqrt_not",
     "phase_shift",
     # -- 2 qubit gates --
     "cnot",
     "swap",
     # -- 3 qubit gates --
     "toffoli",
     "cswap",
     "fredkin",
     "deutsch",
 ]  # type: ignore
 
 
1673b8dc
 def apply(gate, qubits, state):
0232cc30
     """Apply a gate to the specified qubits of a state
 
     Parameters
     ----------
     gate : ndarray[complex]
     qubits : sequence of int
         The qubits on which to act. Qubit 0 is the least significant qubit.
     state : ndarray[complex]
 
     Returns
     -------
     new_state : ndarray[complex]
     """
1673b8dc
     n_gate_qubits = gate.shape[0].bit_length() - 1
     n_state_qubits = state.shape[0].bit_length() - 1
     assert len(qubits) == n_gate_qubits
 
     # We can view an n-qubit gate as a 2*n-tensor (n contravariant and n contravariant
     # indices) and an n-qubit state as an n-tensor (contravariant indices)
     # with each axis having length 2 (the state space of a single qubit).
     gate = gate.reshape((2,) * 2 * n_gate_qubits)
     state = state.reshape((2,) * n_state_qubits)
 
     # Our qubits are labeled from least significant to most significant, i.e. our
     # computational basis (for e.g. 2 qubits) is ordered like |00⟩, |01⟩, |10⟩, |11⟩.
     # We represent the state as a tensor in *row-major* order; this means that the
     # axis ordering is *backwards* compared to the qubit ordering (the least significant
     # qubit corresponds to the *last* axis in the tensor etc.)
     qubit_axes = tuple(n_state_qubits - 1 - np.asarray(qubits))
 
     # Applying the gate to the state vector is then the tensor product over the appropriate axes
     axes = (np.arange(n_gate_qubits, 2 * n_gate_qubits), qubit_axes)
     new_state = np.tensordot(gate, state, axes=axes)
 
     # tensordot effectively re-orders the qubits such that the qubits we operated
     # on are in the most-significant positions (i.e. their axes come first). We
     # thus need to transpose the axes to place them back into their original positions.
     untouched_axes = tuple(
         idx for idx in range(n_state_qubits) if idx not in qubit_axes
     )
     inverse_permutation = np.argsort(qubit_axes + untouched_axes)
     return np.transpose(new_state, inverse_permutation).reshape(-1)
 
 
0a4b3d01
 def _check_valid_gate(gate):
     if not (
         # is an array
         isinstance(gate, np.ndarray)
         # is complex
         and np.issubdtype(gate.dtype, np.complex128)
         # is square
         and gate.shape[0] == gate.shape[1]
         # has size 2**n, n > 1
         and np.log2(gate.shape[0]).is_integer()
d0b4a812
         and gate.shape[0].bit_length() > 1
0a4b3d01
         # is unitary
         and np.allclose(gate @ gate.conjugate().transpose(), np.identity(gate.shape[0]))
     ):
         raise ValueError("Gate is not valid")
 
 
 def n_qubits(gate):
     """Return the number of qubits that a gate acts on.
 
     Raises ValueError if 'gate' does not have a shape that is
     an integer power of 2.
     """
     _check_valid_gate(gate)
d0b4a812
     return gate.shape[0].bit_length() - 1
0a4b3d01
 
 
 def controlled(gate):
     """Return a controlled quantum gate, given a quantum gate.
 
     If 'gate' operates on :math:`n` qubits, then the controlled gate operates
     on :math:`n+1` qubits, where the most-significant qubit is the control.
 
     Parameters
     ----------
     gate : np.ndarray[complex]
         A quantum gate acting on :math:`n` qubits;
         a :math:`2^n×2^n` unitary matrix in the computational basis.
 
     Returns
     -------
     controlled_gate : np.ndarray[(2**(n+1), 2**(n+1)), complex]
     """
     _check_valid_gate(gate)
     n = gate.shape[0]
     zeros = np.zeros((n, n))
     return np.block([[np.identity(n), zeros], [zeros, gate]])
 
 
 # -- Single qubit gates --
 
 #: The identity gate on 1 qubit
 id = np.identity(2, complex)
 #: Pauli X gate
 x = np.array([[0, 1], [1, 0]], complex)
 #: NOT gate
 not_ = x
 #: Pauli Y gate
 y = np.array([[0, -1j], [1j, 0]], complex)
 #: Pauli Z gate
 z = np.array([[1, 0], [0, -1]], complex)
 #: SQRT(NOT) gate
 sqrt_not = 0.5 * (1 + 1j * id - 1j * x)
 #: Hadamard gate
 hadamard = np.sqrt(0.5) * (x + z)
 
 
 def phase_shift(phi):
     "Return a gate that shifts the phase of :math:`|1⟩` by :math:`φ`."
     return np.array([[1, 0], [0, np.exp(1j * phi)]])
 
 
 # -- Two qubit gates --
 
 #: Controlled NOT gate
 cnot = controlled(x)
 #: SWAP gate
 swap = np.identity(4, complex)[:, (0, 2, 1, 3)]
 
 # -- Three qubit gates --
 
 #: Toffoli (CCNOT) gate
 toffoli = controlled(cnot)
 #: Controlled SWAP gate
 cswap = controlled(swap)
 #: Fredkin gate
 fredkin = cswap
 
 
 def deutsch(phi):
     "Return a Deutsch gate for angle :math:`φ`."
     gate = np.identity(8, complex)
     gate[-2:, -2:] = np.array(
         [[1j * np.cos(phi), np.sin(phi)], [np.sin(phi), 1j * np.cos(phi)]]
     )
     return gate