GHZ Experiment¶
In this tutorial, we’ll build a logical GHZ experiment using Loom’s code-factory and execute it on a simulator.
This will show how to:
- Create logical qubits using a code factory
- Interpret the resulting circuit for execution
- Convert and run the interpreted
Ekaon a simulator or backend - Execute the resulting circuit with Stim
Overview¶
In the previous tutorials, we learned how to define a QEC code directly from its stabilizers and logical operators.
Thanks to Loom’s flexible structure, you can define any code you like as long as you know its stabilizers. However, in most cases, we may want to reuse certain blocks multiple times — for instance, to create several blocks of the same code or logical qubits of the same type but with different distances.
This is where a code-factory becomes useful. Moreover, a factory ensures that our logical circuit satisfies the necessary validation requirements to be converted into a format that can run on a backend or simulator platform.
Step 1: Use a Code Factory¶
We’ll begin by building a \(d=3\) surface code block using the RotatedSurfaceCode factory.
dx and dz define the size of the code in the horizontal and vertical direction, while position refer to the position on the lattice of the top-left qubit in the block.
To change the orientation of the qubit, set the optional x_boundary="horizontal (default horizontal).
weight_4_x_schedule defines the schedule of CNOTS for the 4-qubit stabilizers. This is particularly useful to ensure failt tolerance, avoiding "hook errors" when in presence of noise.
Since this depends on the orientation of logical qubit, make sure to select the right combinations when changing optional arguments.
In particular, if x_boundary = "vertical", you should set weight_4_x_schedule = FourBodySchedule.Z.
from loom.eka import Lattice
from loom_rotated_surface_code.code_factory import RotatedSurfaceCode
from loom_rotated_surface_code.utilities import FourBodySchedule
lattice = Lattice.square_2d((11, 13))
q1 = RotatedSurfaceCode.create(
dx=3,
dz=3,
position=(2, 4),
x_boundary="horizontal",
weight_4_x_schedule=FourBodySchedule.N,
weight_2_stab_is_first_row=True,
lattice=lattice,
unique_label="q1",
)
This creates a single surface code block called "q1" positioned with its top-left qubit at (2,4) within the lattice.
Step 2: Create Multiple Logical Qubits¶
To build a GHZ experiment, we’ll need three logical qubits. We can create them by repeating the same process as above, making sure each block occupies a different position on the lattice.
Now we have three independent surface code blocks (q1, q2, and q3) placed in different regions of the lattice. These blocks will act as the logical qubits used to generate a GHZ state.
q2 = RotatedSurfaceCode.create(
dx=3,
dz=3,
position=(6, 0),
x_boundary="horizontal",
weight_4_x_schedule=FourBodySchedule.N,
weight_2_stab_is_first_row=True,
lattice=lattice,
unique_label="q2",
)
q3 = RotatedSurfaceCode.create(
dx=3,
dz=3,
position=(6, 8),
x_boundary="horizontal",
weight_4_x_schedule=FourBodySchedule.N,
weight_2_stab_is_first_row=True,
lattice=lattice,
unique_label="q3",
)
blocks = [q1, q2, q3]
Step 3: Define the GHZ Experiment Operations¶
We can now generate the three-qubit logical GHZ state of the form:
Where the \(L\) subscript stands for "logical".
We can create this state with the following steps:
- Initialize one logical qubit in the \(|+\rangle\) state and the others in \(|0\rangle\).
- Apply a logical CNOT between first and second qubits, where the \(|+\rangle\) qubit acts as the control.
- Apply a logical CNOT between first and third qubits, where the \(|+\rangle\) qubit acts as the control.
After each step, we perform syndrome extraction.
At the end of the circuit we measure all logical qubits in the Z basis.
In Loom, these steps are expressed as time-ordered operation frames. Each frame contains all operations that occur simultaneously in time. Notice how each frame is separated into a different entry in the operations tuple.
Some code-specific operations can be found in the relative code-factory, such as AuxCNOT, which performs logical CNOT gates between surface code blocks.
from loom.eka.operations.code_operation import (
ResetAllDataQubits,
MeasureBlockSyndromes,
MeasureLogicalZ,
)
from loom_rotated_surface_code.operations import AuxCNOT
from loom.eka.operations.code_operation import MeasureLogicalX
n_cycles = 3
operations = (
(
ResetAllDataQubits("q1", state="+"),
ResetAllDataQubits("q2", state="0"),
ResetAllDataQubits("q3", state="0"),
),
(
MeasureBlockSyndromes("q1", n_cycles=n_cycles),
MeasureBlockSyndromes("q2", n_cycles=n_cycles),
MeasureBlockSyndromes("q3", n_cycles=n_cycles),
),
(
AuxCNOT(("q1", "q2")),
),
(
MeasureBlockSyndromes("q1", n_cycles=n_cycles),
MeasureBlockSyndromes("q2", n_cycles=n_cycles),
MeasureBlockSyndromes("q3", n_cycles=n_cycles),
),
(
AuxCNOT(("q1", "q3")),
),
(
MeasureBlockSyndromes("q1", n_cycles=n_cycles),
MeasureBlockSyndromes("q2", n_cycles=n_cycles),
MeasureBlockSyndromes("q3", n_cycles=n_cycles),
),
(
MeasureLogicalZ("q1"),
MeasureLogicalZ("q2"),
MeasureLogicalZ("q3"),
),
)
Step 4: Interpret the Circuit¶
An Eka object contains all the information about your logical circuit — the lattice, blocks, and operations — in a hardware-agnostic format.
This means it describes the experiment logically, without tying it to any specific hardware or simulator.
We need to interpret it using the interpret_eka() function before can be converted into a backend-specific format.
from loom.eka import Eka
from loom.interpreter import interpret_eka
eka_result = Eka(lattice, blocks=blocks, operations=operations)
ghz_circ = interpret_eka(eka_result)
Step 5: Execute the Circuit¶
Finally, our GHZ circuit is ready to be executed or simulated.
Loom supports conversion to multiple formats, making it easy to compare results across backends or simulators.
These converters are contained in the executor package.
Example: Converting to Stim Format¶
Stim is an open-source, high-performance simulator for stabilizer circuits.
We can convert our GHZ experiment to Stim format using the EkaCircuitToStimConverter class.
from loom.executor import EkaCircuitToStimConverter
converter = EkaCircuitToStimConverter()
stim_circuit = converter.convert(ghz_circ, with_ticks=True)
Our logical GHZ circuit in Stim can now be efficiently simulated with Stim!
Adding Noise to the Simulation¶
Loom's executor package also provides additional utilities. For instance, you can annotate your circuit with depolarizing and bit-flip noise using the noise_annotated_stim_circuit() function:
from loom.executor import noise_annotated_stim_circuit
noisy_stim_circuit = noise_annotated_stim_circuit(
stim_circ=stim_circuit,
before_measure_flip_probability=1e-3,
after_clifford_depolarization=1e-3,
after_reset_flip_probability=1e-3,
)
Simulating the Circuit in Stim¶
We can now run the circuit and examine the output of our operations.
We will compare the results of the noiseless circuit with those of its noise-annotated version.
Since we want to verify that the final state is indeed the logical GHZ state, we focus on the parities of \(Z_1 Z_2\) and \(Z_2 Z_3\).
In a noiseless setting, both should have the same parity and therefore each should yield a 0 outcome.
First, we extract the measurement outcomes for the three logical qubits from the sampled circuit.
They correspond to the last three instructions in the Stim circuit, as these were measured last.
We then combine the results for each observable and display how many occurrences have matching parity (0) or differing parity (1), where a 1 indicates the presence of one or more logical flips.
Additionally, we can also count how many detectors flipped in the Stim circuit and compare this number between the two scenarios.
import numpy as np
n_samples = 1000
circuits = {
"noiseless circuit": stim_circuit,
"noisy circuit": noisy_stim_circuit,
}
for name, circuit in circuits.items():
# Sample the Stim circuit
sampler = circuit.compile_sampler()
samples = sampler.sample(shots=n_samples)
# Extract logical measurement outcomes
obs_samples = []
# The last three measurement instructions correspond to logical measurements
for sample_id in (3, 2, 1):
obs_instr = circuit[-sample_id]
meas_indices = [rec.value for rec in obs_instr.targets_copy()]
obs_samples.append(
np.bitwise_xor.reduce(samples[:, meas_indices], axis=1)
)
# Combine observables
samp_dict = {
"Z1Z2": np.bitwise_xor(obs_samples[0], obs_samples[1]),
"Z2Z3": np.bitwise_xor(obs_samples[1], obs_samples[2]),
}
# Count outcomes for each observable
for obs_name, samp in samp_dict.items():
unique_el, counts = np.unique(samp, return_counts=True)
output = {"0": 0, "1": 0}
for value, count in zip(unique_el, counts):
output[str(int(value)))] = int(count)
print(f"{obs_name} counts for {name}: {output}")
# Count detector flips
det_samples_list = []
for stim_det in range(1, circuit.num_detectors + 1):
det_instr = circuit[-stim_det - circuit.num_observables]
meas_indices = [rec.value for rec in det_instr.targets_copy()]
det_samples = np.bitwise_xor.reduce(samples[:, meas_indices], axis=1)
det_samples_list.append(sum(det_samples))
print("Detector flips:", sum(det_samples_list))
print()
Summary¶
In this tutorial, we learned how to:
- Use a
code-factoryto build logical surface code blocks - Realize a logical circuit involving multiple logical qubits
- Interpret the resulting
Eka - Convert the logical circuit to an executable format
- Execute the converted circuit using Stim's
compile_sampler()
Now you can scale this workflow to larger experiments, different error-correction codes, or other simulation backends, using Loom’s modular structure to best suit your needs.