import numpy as np
import perceval as pcvl
Challenge 1: Introduction to the dual-rail or path encoding
1 Encoding Qubits?
The quantum information carried by a qubit can be represented in various ways. Here, we use the photonic quantum computer model, which operates with photons and optical hardware.
This is how the French startup Quandela operates. We will complete all the challenges of this edition using their framework: Perceval. (https://perceval.quandela.net/)
# A beautiful gate
= pcvl.PERM([1, 0])
x_gate pcvl.pdisplay(x_gate)
Working with photons has many advantages. All operations are possible, with direct applications in telecommunications. Scaling is easier and energy consumption is lower.
# Another beautiful gate
= pcvl.BS.H()
hadamard_gate pcvl.pdisplay(hadamard_gate)
There are several ways to encode qubits with photons; we have chosen to focus on dual-rail encoding because it is easier to grasp.
= {
qubits "0": pcvl.BasicState([1, 0]),
"1": pcvl.BasicState([0, 1])
}print("Qubit 0 on the first rail:", qubits["0"])
print("Qubit 1 on the second rail:", qubits["1"])
We can then perform operations on these rails to simulate logic gates. For example, a NOT gate (x_gate
):
# We create a simulation of our circuit: `x_gate`
= pcvl.Processor("Naive", x_gate)
p
# We analyze the output produced with a qubit 0 at the input.
= pcvl.algorithm.Analyzer(p, [qubits["0"]], '*')
analyser pcvl.pdisplay(analyser)
We can also try with multiple inputs:
# Dictionary (inverse of qubit defined above) used to directly display qubits when using functions like
# the analyzer.
= {qubits[k]: k for k in qubits}
qubits_
# The '*' in the previous definition of the analyzer was used to display all possible outputs. Here,
# I specified which outputs I wanted to have them in the order I desire.
= pcvl.algorithm.Analyzer(
analyser
p, =list(qubits.values()),
input_states=list(qubits.values()),
output_states=qubits_
mapping
) pcvl.pdisplay(analyser)
All operations can be represented by unitary matrices. For example, the previous circuit can be summarized by:
pcvl.pdisplay(x_gate.definition())
In general: \[ |\phi\rangle = U|\psi\rangle \]
For instance: \[ |\phi\rangle = X|1\rangle = \begin{pmatrix} 0 & 1 \\ 1 & 0 \end{pmatrix} \begin{pmatrix} 0 \\ 1 \end{pmatrix} = \begin{pmatrix} 1 \\ 0 \end{pmatrix} = |0\rangle \]
The concatenation of circuits translates to a simple matrix multiplication:
= x_gate // x_gate
super_circuit # We can no longer directly call .definition() pcvl.pdisplay(super_circuit.compute_unitary())
We can try with the analyzer:
= pcvl.Processor("Naive", super_circuit)
p = pcvl.algorithm.Analyzer(
analyser
p, =list(qubits.values()),
input_states=list(qubits.values()),
output_states=qubits_
mapping
) pcvl.pdisplay(analyser)
Without the mapping:
= pcvl.algorithm.Analyzer(
analyser
p, =list(qubits.values()),
input_states=list(qubits.values())
output_states
) pcvl.pdisplay(analyser)
Or even observe our magnificent super circuit that serves no purpose:
pcvl.pdisplay(super_circuit)
2 The Beginning of Problems…
NOT gates are all well and good, but we won’t get much done with just those. So, what happens if we try with another gate?
= pcvl.Processor("Naive", hadamard_gate)
p = pcvl.algorithm.Analyzer(
analyser
p, =[qubits["0"]],
input_states=list(qubits.values()),
output_states=qubits_
mapping
) pcvl.pdisplay(analyser)
What happened? We used the superstar of quantum gates: the Hadamard gate. It’s the gateway to the quantum world, allowing a qubit to be prepared in a superposed state, as shown here:
\[ H = \frac{1}{\sqrt{2}} \begin{pmatrix} 1 & 1 \\ 1 & -1 \end{pmatrix} \]
If we add a \(0\) at the input: \[ |\phi\rangle = H|0\rangle = \frac{1}{\sqrt{2}} \begin{pmatrix} 1 & 1 \\ 1 & -1 \end{pmatrix} \begin{pmatrix} 1 \\ 0 \end{pmatrix} = \frac{1}{\sqrt{2}} \begin{pmatrix} 1 \\ 1 \end{pmatrix} \]
With: \[ \frac{1}{\sqrt{2}} \begin{pmatrix} 1 \\ 1 \end{pmatrix} = \frac{1}{\sqrt{2}} \begin{pmatrix} 1 \\ 0 \end{pmatrix} + \frac{1}{\sqrt{2}} \begin{pmatrix} 0 \\ 1 \end{pmatrix} = \frac{1}{\sqrt{2}} |0\rangle + \frac{1}{\sqrt{2}}|1\rangle \]
We can create a Hadamard gate with a semi-reflective mirror!
In practice, it will be a bit different though…
pcvl.pdisplay(hadamard_gate)
You may have noticed that the output from the analyzer is not the same as that of the equations. Indeed, \(|\phi\rangle\) is a quantum state, which can be complex: \(|\phi\rangle = \frac{i}{\sqrt{2}} |0\rangle - \frac{i}{\sqrt{2}} |1\rangle\), whereas the probabilities of observing one state over another are real! To convert to probabilities, we simply square the amplitudes. For instance, in the previous example: \(\Pr(|0\rangle) = \left|\frac{1}{\sqrt{2}}\right|^2 = \frac{1}{2}\). This matches the output of our analyzer.
To obtain the complex amplitudes, we can use a backend:
= pcvl.BackendFactory.get_backend("Naive")
backend
backend.set_circuit(hadamard_gate)"0"])
backend.set_input_state(qubits[= backend.prob_amplitude(qubits["0"]), backend.prob_amplitude(qubits["1"])
ampl0, ampl1 print(f"|phi> = {ampl0} |0> + {ampl1} |1>")
We indeed find the result of the equations: \[ |\phi\rangle = \frac{1}{\sqrt{2}} |0\rangle + \frac{1}{\sqrt{2}} |1\rangle \]
This series of challenges is not intended to delve into theoretical details but to explore the ecosystem of quantum computers through practice. If you want to learn more, you can checkout IBM’s quantum courses: https://learning.quantum.ibm.com/, which are a very good starting point.
3 A New Challenger: The Phase Shifter
In addition to having complex amplitudes, we can manipulate them using a very powerful tool: the phase shifter. It applies to one rail and performs the operation: \(e^{i\theta}\).
= pcvl.PS(np.pi/3)
phase_shifter pcvl.pdisplay(phase_shifter)
In a circuit, it looks like this:
= pcvl.Circuit(2) // (0, phase_shifter) # The 0 corresponds to the rail number where our
circuit_ps # component is positioned.
pcvl.pdisplay(circuit_ps)
With its associated unitary matrix:
pcvl.pdisplay(circuit_ps.compute_unitary())
It is also possible to directly observe the amplitudes:
= pcvl.BackendFactory.get_backend("Naive")
backend_ps
backend_ps.set_circuit(circuit_ps)"0"])
backend_ps.set_input_state(qubits[= backend_ps.prob_amplitude(qubits["0"]), backend_ps.prob_amplitude(qubits["1"])
ampl0, ampl1 print(f"|phi> = {ampl0} |0> + {ampl1} |1>")
We can have fun reproducing known gates, like the NOT gate:
= hadamard_gate // (1, pcvl.PS(np.pi)) // hadamard_gate
super_not pcvl.pdisplay(super_not)
= pcvl.Processor("Naive", super_not)
p = pcvl.algorithm.Analyzer(
analyser
p, =list(qubits.values()),
input_states=list(qubits.values()),
output_states=qubits_
mapping
) pcvl.pdisplay(analyser)
What happened? In the canonical basis \(\left(|0\rangle = \begin{pmatrix} 1 \\ 0 \end{pmatrix}, |1\rangle = \begin{pmatrix} 0 \\ 1 \end{pmatrix}\right)\), applying a phase shift on one of the two rails would not have changed anything. However, in another basis: \[ H|0\rangle = H \begin{pmatrix} 1 \\ 0 \end{pmatrix} = \frac{1}{\sqrt{2}} \begin{pmatrix} 1 \\ 1 \end{pmatrix} = |+\rangle \]
\[ H|1\rangle = H \begin{pmatrix} 0 \\ 1 \end{pmatrix} = \frac{1}{\sqrt{2}} \begin{pmatrix} 1 \\ -1 \end{pmatrix} = |-\rangle \]
We notice that “0” and “1” differ only by a phase shift, here a shift of \(e^{i\pi}\) on the second rail. So, we simply switch to the new basis, phase shift the second rail, and return to the canonical basis (\(H\) is its own inverse, so it’s easy).
4 Your Turn to Play!
For this part, we will use the generalization of the Hadamard gate: a Beam Splitter. Here are its parameters:
# We can define symbolic variables:
= pcvl.P('α')
symbolic_alpha = pcvl.BS(theta=symbolic_alpha)
simple_bs pcvl.pdisplay(simple_bs.U)
# Then assign them a value:
'α': np.pi/2})
simple_bs.assign({ pcvl.pdisplay(simple_bs.compute_unitary())
# Or change their value later:
symbolic_alpha.set_value(np.pi) pcvl.pdisplay(simple_bs.compute_unitary())
4.1 Step 1
Considering the following circuit (which corresponds to the definition of the Beam Splitter above):
= simple_bs
step_one pcvl.pdisplay(step_one)
And an input value: \(|0\rangle\),
What value of \(\alpha\) should be chosen to have a 0.9 probability of getting \(|1\rangle\)?
= ...
alpha raise NotImplementedError
'α': alpha})
step_one.assign({ pcvl.pdisplay(step_one)
= pcvl.Processor("Naive", step_one)
p_step_one = pcvl.algorithm.Analyzer(
a_step_one
p_step_one, =[qubits["0"]],
input_states=list(qubits.values()),
output_states=qubits_
mapping
)
print("Expected output: 1/10 for 0 and 9/10 for 1")
pcvl.pdisplay(a_step_one)# assert np.isclose(a_step_one.distribution[0][1].real, 0.9)
4.2 Step 2
Considering the following circuit and an input value of \(|0\rangle\), find the values of \(\beta\) and \(\gamma\) to prepare a qubit in the state: \[ |\psi\rangle = \frac{\sqrt{3}}{2} |0\rangle + \left(\frac{\sqrt{3}}{4} - \frac{i}{4} \right)|1\rangle \]
= pcvl.P("β")
symbolic_beta = pcvl.P("γ")
symbolic_gamma = pcvl.BS(theta=symbolic_beta) // (1, pcvl.PS(phi=symbolic_gamma))
step_two pcvl.pdisplay(step_two)
= ...
beta = ...
gamma raise NotImplementedError
"β": beta, "γ": gamma})
step_two.assign({
= pcvl.BackendFactory.get_backend("Naive")
b_step_two
b_step_two.set_circuit(step_two)"0"])
b_step_two.set_input_state(qubits[
= b_step_two.prob_amplitude(qubits["0"]), b_step_two.prob_amplitude(qubits["1"])
ampl0, ampl1
= f"|φ> = {np.round(ampl0, 2)} |0> + {np.round(ampl1, 2)} |1>"
res = f"|φ> = {np.round(np.sqrt(3) / 2 + 0j, 2)} |0> + {np.round(np.sqrt(3) / 4 - 1j / 4, 2)} |1>"
sol
print(f"Result : {res}")
print(f"Solution: {sol}")
# We ensure that the answer is indeed equal to the solution:
# assert res == sol
4.3 One Last Tool for the Road: The Bloch Sphere
It turns out that the previous two results can be generalized. With the following circuit, it is possible to prepare any state. (https://perceval.quandela.net/docs/components.html#beam-splitter)
= pcvl.BS()
super_preparator pcvl.pdisplay(super_preparator)
The definition is actually much more general than what we used previously:
pcvl.pdisplay(super_preparator.definition())
For example, with random values:
import random
= [round(random.uniform(0, np.pi), 2) for _ in range(5)]
phi_tl, phi_tr, phi_bl, phi_br, theta_ = pcvl.BS(phi_tl=phi_tl, phi_tr=phi_tr, phi_bl=phi_bl, phi_br=phi_br, theta=theta_)
super_preparator pcvl.pdisplay(super_preparator.U)
Which allows us to prepare:
= pcvl.BackendFactory.get_backend("Naive")
backend
backend.set_circuit(super_preparator)"0"])
backend.set_input_state(qubits[
= backend.prob_amplitude(qubits["0"]), backend.prob_amplitude(qubits["1"])
ampl0, ampl1
print(f"|φ> = {np.round(ampl0, 2)} |0> + {np.round(ampl1, 2)} |1>")
To represent a qubit, it is possible to use the Bloch sphere.
The \(Z\) axis corresponds to the states \(|0\rangle\) and \(|1\rangle\), the \(X\) axis corresponds to the states \(|+\rangle\) and \(|-\rangle\) encountered earlier, and the \(Y\) axis corresponds to the states \(|i\rangle\) and \(|-i\rangle\).
We can easily visualize a state with the Qiskit library:
from qiskit.visualization import plot_bloch_multivector
from qiskit.quantum_info import Statevector
= Statevector([ampl0, ampl1]) # Not to be confused with StateVector from Perceval
state_vector plot_bloch_multivector(state_vector)
A simple \(|0\rangle\) :
"0"])) # Same as `plot_bloch_multivector([1, 0])` plot_bloch_multivector(np.array(qubits[
A small utility for the next part:
def circuit_to_state_vector(circuit):
= pcvl.BackendFactory.get_backend("Naive")
backend
backend.set_circuit(circuit)"0"])
backend.set_input_state(qubits[= backend.prob_amplitude(qubits["0"]), backend.prob_amplitude(qubits["1"])
ampl0, ampl1 return Statevector([ampl0, ampl1])
= lambda circuit: plot_bloch_multivector(circuit_to_state_vector(circuit)) plot_bloch
Our state \(|+\rangle\), created by applying a Hadamard gate to \(|0\rangle\) \(\left(|+\rangle = H|0\rangle\right)\):
plot_bloch(hadamard_gate)
Given that measurement is a projection, in our case, onto the basis \(\left\{|0\rangle, |1\rangle\right\}\), it is easier to see with this representation that the state \(|+\rangle\), when measured, is equally likely to yield \(0\) as it is to yield \(1\). It is also straightforward to see that the global phase (a rotation around the \(Z\) axis) will have no effect on the measurement. This is reflected in the calculation, for \(|\psi\rangle = e^{i\theta} \alpha |0\rangle + e^{i\theta} \beta |1\rangle\): \[ \Pr(0) = \left|e^{i\theta}\alpha\right|^2 = |\alpha|^2 \] \[ \Pr(1) = \left|e^{i\theta}\beta\right|^2 = |\beta|^2 \]
Furthermore, we can also understand why the NOT gate is called an \(X\) gate: A rotation of \(\pi\) around the \(X\) axis changes the state \(|0\rangle\) to \(|1\rangle\), and vice versa.
4.4 Final Step:
Again, a few utility functions. As their names indicate, these functions allow rotations around the different axes with a chosen angle. To combine them, simply use the // operator.
(PS: Remember the right-hand rule :)
= lambda x: pcvl.Circuit(2) // (0, pcvl.PS(np.pi)) // pcvl.BS.Rx(theta=x) // (0, pcvl.PS(np.pi))
x_rot = lambda x: pcvl.BS.Ry(theta=x)
y_rot = lambda x: pcvl.BS.H() // x_rot(x) // pcvl.BS.H() z_rot
Moving around the sphere then becomes trivial!
# (All paths start from |0> with the functions I've written)
= x_rot(-np.pi/4) // z_rot(-np.pi/4)
the_way plot_bloch(the_way)
pcvl.pdisplay(the_way)
Perform the following path:
Starting point: \[ |\phi_{start}\rangle = \frac{\sqrt{2+\sqrt{2}}}{2} |0\rangle + \frac{\sqrt{2-\sqrt{2}}}{2} e^{-i\frac{\pi}{4}} |1\rangle \]
Step: \[ |\phi_{step}\rangle = |-\rangle \]
Endpoint: \[ |\phi_{finish}\rangle = \frac{\sqrt{2-\sqrt{2}}}{2} |0\rangle + \frac{\sqrt{2+\sqrt{2}}}{2} e^{i\frac{\pi}{4}} |1\rangle \]
Coordinates of the starting point:
= np.array([np.sqrt(2+np.sqrt(2))/2, np.sqrt(2-np.sqrt(2))/2 * (np.sqrt(2)/2 - 1j * np.sqrt(2)/2)])
start_state plot_bloch_multivector(start_state)
Coordinates of the step:
= np.array([np.sqrt(2)/2, -np.sqrt(2)/2])
step_state plot_bloch_multivector(step_state)
Coordinates of the end:
= np.array([np.sqrt(2-np.sqrt(2))/2, np.sqrt(2+np.sqrt(2))/2 * (np.sqrt(2)/2 + 1j * np.sqrt(2)/2)])
finish_state plot_bloch_multivector(finish_state)
= y_rot(np.pi/4) // z_rot(-np.pi/4) # To position oneself at the starting point:
start
= ...
delta = ...
epsilon = ...
zeta = ...
eta raise NotImplementedError
# Another way to chain the gates
= (start
final_step 0, z_rot(delta))
.add(0, y_rot(epsilon)) # Arrived at the Hadamard step
.add(0, y_rot(zeta))
.add(0, z_rot(eta)) # End of the journey!
.add(
) plot_bloch(final_step)
5 Did You Notice?
This is a non-important physical point for the validation of the challenge, I just mention it for those who wonder.
The amplitudes of start
and start_state
are not the same!
Indeed, this is a point I haven’t addressed: global phases and relative phases. A global phase is a phase of the form \(e^{i\theta}|\phi\rangle\), it doesn’t change anything in our calculations because it doesn’t allow us to distinguish between \(|0\rangle\) and \(|1\rangle\). It is not visible on the Bloch sphere but is in calculating the amplitudes.
On the other hand, the relative phase, a phase of the form \(|\psi\rangle = \alpha|0\rangle +\beta e^{i\theta}|1\rangle\), may have an impact depending on the following calculations (see for example the phase kickback technique, central in Shor’s algorithm). It is visible on the Bloch sphere and in calculating the amplitudes.
So, when I defined:
= np.array([np.sqrt(2+np.sqrt(2))/2, np.sqrt(2-np.sqrt(2))/2 * (np.sqrt(2)/2 - 1j * np.sqrt(2)/2)]) start_state
The amplitudes are: \(0.92|0\rangle + (0.27 - 0.27i)|1\rangle\). That is: \[ |\phi_{start}\rangle = \frac{\sqrt{2+\sqrt{2}}}{2} |0\rangle + \frac{\sqrt{2-\sqrt{2}}}{2} e^{-i\frac{\pi}{4}} |1\rangle \]
While when I defined:
= y_rot(np.pi/4) // z_rot(-np.pi/4) start
The amplitudes are: \((-0.35+0.15i)|0\rangle+ (-0.85-0.35i)|1\rangle\). That is: \[ |\phi_{start}'\rangle = \frac{\sqrt{2+\sqrt{2}}}{2} e^{i\frac{\pi}{8}} |0\rangle + \frac{\sqrt{2-\sqrt{2}}}{2} e^{-i\frac{\pi}{8}} |1\rangle \]
We notice that \(|\phi_{start}\rangle\) and \(|\phi_{start}'\rangle\) differ only by a global phase:
\[ |\phi_{start}'\rangle = e^{i\frac{\pi}{8}} |\phi_{start}\rangle \]
In my example of super_not
above, I used a relative phase, whereas when I explained the \(X\) and \(Z\) rotations just before the final step, I was talking about global phase!
6 Flag Retrieval
Run the cell below by providing the URL given in the statement. The tests performed are the same as in this notebook, but there may be issues if you have run the cells multiple times. Feel free to restart the entire notebook at once.
import requests as rq
def circuit_to_list(circuit):
return [[(x.real, x.imag) for x in l] for l in np.array(circuit.compute_unitary())]
= {
d "step_one": circuit_to_list(step_one),
"step_two": circuit_to_list(step_two),
"final_step": circuit_to_list(final_step)
}
= ...
URL # URL = "http://localhost:8000"
# URL = "https://perceval.challenges.404ctf.fr"
+ "/healthcheck").json() rq.get(URL
+ "/challenges/1", json=d).json() rq.post(URL