Writing quantum programs

This framework provides tools to write advanced quantum jobs using only few lines of code. Qaptiva supports several quantum paradigms, such as the gate-based paradigm, the analog paradigm, and the quantum annealing paradigm. All of these paradigms are detailed in the writing section, in user guide.

This page focus on the gate-based paradigm. Module qat.lang contains all Python objects required to create these quantum circuits, which include:

  • the native gates of Qaptiva framework

  • a programming library to create a quantum Circuit using these native gates. Programming a quantum circuit can rely on:

    • a function - gates are added to the circuit using either qrout() or qfunc() decorators

    • the Program class - gates are added to the circuit using apply() method

Example of a grover algorithm

Let’s write a simple Grover algorithm. Grover is a quantum search algorithm that can find an element in an unstructured search space quadratically faster than a randomized classical algorithm. In this search model, the problem is specified by an oracle, i.e a function \(\mathcal{X}\rightarrow \{0, 1\}\), and we are looking for an element \(x \in \mathcal{X}\) such that \(f(x) = 1\).

The algorithm consists in alternating two operations \(\pi \sqrt{\frac{1}{a}}/4\) times where \(a = \frac{|f^{-1}(\{1\})|}{|\mathcal{X}|}\) is the probability of finding the searched element in the uniform distribution.

These operations are:

  • an oracle \(U_f: |x \rangle \mapsto (-1)^{f(x)}|x\rangle\)

  • a diffusion \(U_D = I - 2|s\rangle\langle s|\) where \(|s\rangle = \frac{1}{\sqrt{|\mathcal{X}|}} \sum_{x\in\mathcal{X}} |x\rangle\)

Let’s dive in the details of their implementation for a simple search!

The diffusion

To keep things simple we will consider the following search space: \(\mathcal{X} = \{0, 1\}^{2k}\). In this setting, a diffusion can be implemented as follows:

  • First, we will put all the qubits in the diagonal basis by applying a wall of H gates.

  • We can then flip the amplitude of the \(|0..0\rangle\) state by flipping all qubits using a wall of \(X\) gates and applying a controlled \(Z\) gate on all qubits.

  • Finally, we can undo our basis changes by applying a wall of \(X\) followed by a wall of \(H\)

We will write a python function that given a number \(k\) returns a diffusion routine over \(2k\) qubits:

import numpy as np
# everything we need to write a quantum circuit
from qat.lang import *

# This is a standard implementation of Grover's diffusion
@qrout(unroll=False)
def diffusion(k):
    for wire in range(2 * k):
        H(wire)
        X(wire)
    Z.ctrl(2 * k - 1)(list(range(2*k)))
    for wire in range(2 * k):
        X(wire)
        H(wire)
import numpy as np
# everything we need to write a quantum circuit
from qat.lang import *

# This is a standard implementation of Grover's diffusion
def diffusion(k):
    routine = QRoutine()
    wires = routine.new_wires(2 * k)
    for wire in wires:
        H(wire)
        X(wire)
    Z.ctrl(2 * k - 1)(wires)
    for wire in wires:
        X(wire)
        H(wire)
    return routine

As you can see, we repeat a lot of code to do basis change and revert them. We can simplify the code by using a compute/uncompute block:

@qrout(unroll=False)
def diffusion(k):
    with compute():
        for wire in range(2 * k):
            H(wire)
            X(wire)
    Z.ctrl(2 * k - 1)(list(range(2*k)))

Notice how we don’t need to explicitely uncompute the H and X gates. These gates will be automatically uncomputed when exiting the routine.

def diffusion(k):
    routine = QRoutine()
    wires = routine.new_wires(2 * k)
    with routine.compute():
        for wire in wires:
            H(wire)
            X(wire)
    Z.ctrl(2 * k - 1)(wires)
    routine.uncompute()
    return routine

This is clearer now: We have our walls of \(H\) and \(X\) gates, our controlled \(Z\) gate, and we undo our walls using the uncomputation.

The oracle

In this space, we will look for palindromes: bit strings that are their own mirrors. We will implement our oracle as follows (remember that we need to flip the sign of the amplitude of all palindromes):

  • First we will compute the xor of \(b_1\) and \(b_{2k}\), \(b_2\) and \(b_{2k-1}\), etc. We will

    do these operations in place in the second half of the bit string.

  • Then we will flip the amplitude of our state if and only if its second half is \(0...0\). To do so, we will flip all bits in the second half (using \(X\) gates) and perform a controlled \(Z\) gate on the second half, thus flipping the amplitude if and only if all qubits are set to \(1\).

  • Finally, we can revert to the original state by uncomputing the bit flips and xors (once again we will use a compute/uncompute block)

@qrout(unroll=False)
def is_palindrome(k):
    first_half = list(range(k))
    second_half = list(range(k, 2 * k))
    with compute():
        for w1, w2 in zip(first_half, reversed(second_half)):
            CNOT(w1, w2)
        for w2 in second_half:
            X(w2)
    Z.ctrl(k - 1)(second_half)
def is_palindrome(k):
    routine = QRoutine()
    first_half = routine.new_wires(k)
    second_half = routine.new_wires(k)
    with routine.compute():
        for w1, w2 in zip(first_half, reversed(second_half)):
            CNOT(w1, w2)
        for w2 in second_half:
            X(w2)
    Z.ctrl(k - 1)(second_half)
    routine.uncompute()
    return routine

And this is it. We can now run a Grover algorithm to find palindromes! There are exactly \(2^k\) palindromes over \(2k\) bits, hence we will need to perform \(\approx \pi\sqrt{2^k}/4\) iterations to find a palindrome with good probability.

k = 2

@qrout
def grover():
    qbits = list(range(2 * k))
    diff = diffusion(k)
    oracle = is_palindrome(k)

    # We start by a uniform superposition of bit strings:
    for qbit in qbits:
        H(qbit)

    nsteps = int(np.pi * np.sqrt(2 ** k) / 4)
    for _ in range(nsteps):
        oracle(qbits)
        diff(qbits)

result = grover().run()

for sample in result:
    print(sample.state, sample.probability)
|0000> 0.2499999999999995
|0110> 0.2499999999999995
|1001> 0.2499999999999995
|1111> 0.2499999999999995
k = 2
grover = Program()
qbits = grover.qalloc(2 * k)
diff = diffusion(k)
oracle = is_palindrome(k)

# We start by a uniform superposition of bit strings:
for qbit in qbits:
    H(qbit)

nsteps = int(np.pi * np.sqrt(2 ** k) / 4)
for _ in range(nsteps):
    oracle(qbits)
    diff(qbits)

# Build a circuit
circuit = grover.to_circ()

# Build a job
job = circuit.to_job()

from qat.qpus import get_default_qpu

# Evaluate the job and print the output probabilities
result = get_default_qpu().submit(job)
for sample in result:
    print(sample.state, sample.probability)
|0000> 0.2499999999999995
|0110> 0.2499999999999995
|1001> 0.2499999999999995
|1111> 0.2499999999999995

As you can see, all the bit strings we can sample (with decently high probability) are palindromes!

Of course, this example is not particularly helpful to solve practical problems, but this framework comes with high level constructs that can help you write more advanced oracles. If you are curious, you can have a look at the oracles section of the documentation to see how to write complicated oracles relying on custom data structures.