4.5. InterpretationStep description
InterpretationStep is a mutable data structure that is used to construct the QEC circuit and by-products (like syndromes and detectors) that are required to run a QEC experiment.
As its name suggest it represents a step in the interpretation process.
When interpreting an Eka object, we need to translate the Operation that describe actions on the quantum circuit from a higher level of abstraction (either logical or code level) down to the circuit level. In addition to the circuit, we also need to generate components that are necessary for the QEC routines.
These components can either be Syndrome or Detector and they are used to locate and reference measurements in the circuit that are inputs to the decoding process.
InterpretationStep serves exactly this purpose, it accumulates gates and sub-circuits with each operations, creates the relevant decoding objects and keeps track of the evolution of the code in time.
At the output of the interpretation, we get an InterpretationStep instance (often called interpreted_eka or final_step) that encompasses the whole QEC circuit and can then be sent for execution or simulation.
Since InterpretationStep accumulates information, most fields of the object are mutable and connect different processes together.
4.5.1. Output components: What the world needs
Currently, the interpretation process returns the final instance of InterpretationStep that bears all changes that happened during interpretation.
This is very convenient for testing and debugging but it is not the minimum information: only a few fields are actually necessary to run a QEC routine on a QPU and/or simulator.
This is what we will describe here as output components.
final_circuit:Circuit
This is the circuit that we want to run to perform the task described in Eka.
At the end of interpretation, this circuit may still be in a recursive format (for ease of reading and conciseness) but it can be both expanded into base gates and/or converted into different executable formats (stim, qasm, pennylane, etc.).
syndromes:tuple[Syndrome, ...]
The syndromes are collection of measurements that describe stabilizer measurements.
It is assumed that the value of the stabilizer measurement is given by the sum modulo 2 of all physical measurements (stored as Detector ).
detectors:tuple[Detector, ...]
The detectors are collections of syndromes.
It is a different paradigm for detecting errors by comparing state of syndromes rather than measurements directly.
Detectors are typically created such that if the value of a syndrome changes in time, the subsequent syndromes are compared to this new value.
They are typically used in matching algorithms to detect errors in space and time.
These are the values we input to the decoding algorithms (as well as Syndrome).
logical_observables:tuple[LogicalObservable, ...]
The logical observables are collections of physical measurements that add up to the value of the measurement of a logical qubit. The value of a logical observable is given by the sum modulo all the physical measurements it is composed of.
4.5.2. Building components: What the world does not want to see
InterpretationStep also serves as an accumulator object that is populated during interpretation of the different operations.
This offers some convenience to access objects for debugging and plotting utilities.
intermediate_circuit_sequence:tuple[tuple[Circuit, ...], ...]
This is a time-ordered sequence of gate that is populated throughout interpretation. The circuit representing an operation is added to it directly, either in an existing time step or in the subsequent one depending on the time-ordering of operations themselves. This object is also used for temporary storage of sub-components of composite operations. Composite operations will first populate this object with sub-operation circuits, pop the circuit sequence off to wrap it correctly and then add it back as a composite circuit. The final circuit is created from the final value of this sequence.
stabilizer_evolution:dict[str, tuple[str, ...]]
This dictionary keeps track of the evolution of stabilizers during lattice surgery operations. It maps the uuid of a new stabilizer (key) to the previous stabilizer(s) value(s). A stabilizer being divided into multiple will be stored as multiple keys with the same value. If two stabilizers are merged into one, the key will be the resulting stabilizer and the value will be a tuple of the two initial stabilizers.
Note that keys are single uuid and values are always tuples of uuids (can also be tuples of a single value).
logical_x_evolution/logical_z_evolution:dict[str, tuple[str, ...]]
This dictionary is used to describe the evolution of logical operators during lattice surgery operations. It maps the uuid of the new operator (key) to the previous operator(s) and stabilizer(s) that create the new operator. In the case an operator is transformed by multiplying it with stabilizers, these stabilizers will be added to the evolution dictionary.
The evolution dictionaries are also used to propagate updates using update_logical_operator_updates_MUT().
If a logical operator is included in the evolution of another, then its updates will be propagated to the next.
In the following examples, the propagation is ignored unless explicitly written.
block_evolution:dict[str, tuple[str, ...]]
This dictionary keeps track of the evolution of blocks using their UUID. Similarly to stabilizers, blocks that are transformed during an operation will have their id stored in the dictionary in a final-to-initial mapping.
block_qec_rounds:dict[str, int]
This counter keeps track of how many rounds of syndrome extraction were performed on a code block.
It is used to create the measurement registers in which the classical bits referring to syndromes are stored.
The round field of Syndrome objects created at interpretation is determined by the value of this object.
Note that the counter is reset every time a block is transformed (any Operation outside of MeasureBlockSyndromes modifies the code block).
This is a counter that is local in time and not bound to the physical location of the qubits (or Channel ).
cbit_counter:dict[str, int]
This counter keeps track of how many times a qubit is measured during the whole set of operations.
It maps the label of the classical channel that is the target of the measurement operation to the index at which that measurement is stored.
The cbit_counter field is used to generate the Cbit s automatically during interpretation.
The method get_new_cbit_MUT is responsible of incrementing the counter and returning the Cbit.
E.g. qubit "(4, 2, 0)" is measured in the classical register "c_(4, 2, 0)" for the third time, the channel used will be labelled "c_(4, 2, 0)_2" and the resulting cbit counter is {"c_(4, 2, 0)": 2}.
The associated Cbit is (c_(4, 2, 0), 2).
During the next round of syndrome extraction, if "(4, 2, 0)" is measured again onto the register "c_(4, 2, 0)", get_new_cbit_MUT will be called and cbit_counter will be incremented. The channel created will be labelled "c_(4, 2, 0)_3".
The resulting Cbit will be (c_(4, 2, 0), 3).
channel_dict:dict[str, Channel]
This dictionary is a mapping between UUIDs and their respective channel. This is used for convenience to access the channels outside of the right context.
stabilizer_updates:dict[str, tuple[Cbit, ...]]
This dictionary is used to describe the measurements we keep track of to ensure that the output stabilizers are in a deterministic state (in the noiseless case). The measurements themselves may not be, but the final value associated with the stabilizer measurement should be (assuming they have been measured in the past and the block is in a quiescent state). Whenever data qubits that are part of a stabilizer are measured, they need to be included in the update dictionaries in some way.
logical_x/z_operator_updates:dict[str, tuple[Cbit, ...]]
This dictionary is used to describe the measurements we keep track of to ensure that the output observable is deterministic. The measurements themselves may not be deterministic but the final product is. Whenever data qubits that are included in logical operators are measured, they need to be included in the update dictionaries in some way. This is also the case when a logical operator is displaced by multiplying it with a stabilizer. In that case, the most recent syndrome should be included in the updates too.
In some cases like in the lattice surgery phase, updates of the \(Z\) operator should be appended to the \(X\) operator updates.
block_history:tuple[tuple[Block, ...]]
This fields stores the full history of blocks for the given list of operations. It currently creates a tuple of blocks every time an operation is executed.
This could be improved by only reflecting changes of a full time step in the block history (and not every single operation within these time steps).
is_frozen:bool
This flag is used to signal that interpretation is completed and the fields may not be mutated anymore.
4.5.3. Usage in the wild
Here are examples of fields that are updated throughout interpretation of lattice surgery operations.
The two most important (and tricky) fields to modify are updates and evolution.
These fields are responsible for the automatic generation of Syndrome and corrections.
A convention that we follow for the two codes described here is that logical operators are tied to the top-left qubit (minimum coordinates in all directions). The consequence of this is that these operators are displaced if we add new qubits to the top-left of the block. E.g. growing a rotated surface code to the left will displace the logical operators by the same distance as specified in grow.
4.5.4. Examples for the repetition code
Notation:
Long logical describes the logical that spans all qubits and which pauli is opposite to the check type.
Short logical describes the logical operator that only acts on a single qubit and is of the same pauli type as the checks/stabilizers.
Required stabilizers describe the Stabilizer objects (sometimes by their UUID) that are required to go from one logical operator to the other.
They satisfy the relation: \(\{S_{\text{required}}\} = \{S_i | L' = L\prod_{i}S_i\}\) , where \(L\) and \(L'\) are respectively the initial and final logical operators.
Required syndromes describe the Syndrome objects corresponding to the required stabilizers measurements.
Grow is basically an identity operation, though one of the operators (the long operator) will get longer.
The operation consists of resetting qubits in the grow direction (left or right) in the basis that matches the code stabilizers.
The evolution of operators is then:
# The long operator gets longer evolution = { new_long_op.uuid: (old_long_op.uuid,), }
where
old_long_opgrew intonew_long_op.Nothing happens to the other operators.
Note that by default a grow towards the left will displace the associated logical operator (short/single qubit operator), populating the evolution dictionary with the required stabilizers and update dictionary with the most recent syndromes:
# The long operator gets longer and the short one is displaced evolution = { new_long_op.uuid: (old_long_op.uuid,) new_short_op.uuid: (old_short_op.uuid,) + tuple(stab.uuid for stab in stabs_required) } updates = { new_short_op.uuid: tuple(meas for meas in required_syndromes) }
if and only if the (short) logical operator is displaced.
Shrink can also be considered an identity operation, though we are measuring some data qubits and need to keep these into account in the update dictionaries. If the operator is not displaced by the shrink, we have:
evolution = { new_long_op.uuid: (old_long_op.uuid,) } updates = { new_long_op.uuid: tuple(meas for meas in shrink_measurements) }
where
shrink_measurementsare all the measurements on data qubits that are part ofold_long_op.data_qubits.The new operator
new_long_opalso inherits updates fromold_long_op. If we shrink a repetition code from the left, the short operator is displaced, resulting in:evolution = { new_short_op.uuid: (old_short_op.uuid,) + tuple(stab.uuid for stab in stabs_required) } updates = { new_short_op.uuid: tuple(meas for meas in shrink_measurements) + tuple(meas for meas in required_syndromes) }
Merge is equivalent to a joint measurement of two blocks in the basis of the shared logical operator (the short one). This is not a unitary operation because the number of logical operators after the operation is reduced.
In the case of a merge, the final long operator is made up of the two initial long operators. If the short operator is not displaced, we have:
evolution = { new_long_op.uuid: (old_long_op_1.uuid, old_long_op_2.uuid,) }
The new operator will inherit updates from the previous operators. As usual, if the preserved (short) operator needs to be displaced, we need to account for it in the evolution and update dictionaries, resulting in:
evolution = { new_long_op.uuid: (old_long_op_1.uuid, old_long_op_2.uuid,), new_short_op.uuid: (old_short_op_1,) + tuple(stab.uuid for stab in stabs_required) } updates = { new_short_op.uuid: tuple(meas for meas in required_syndromes) }
Split distributes the logical information in two blocks that are separated in space. This is done through physical qubits measurements. Split can be understood as the converse operation to merge. The measurements occurring during a split need to be kept track of to update the state of logical operators and stabilizers.
If no logical operator is measured out (
new_short_op_1is the same asold_short_op_1), we have:# Only one of the operators inherits updates from the initial operator evolution = { new_long_op_1.uuid: (old_long_op.uuid,) new_long_op_2.uuid: (old_long_op.uuid,) new_short_op_2.uuid: (old_short_op.uuid,) + tuple(stab.uuid for stab in stabs_required) } updates = { new_long_op_1: (split_meas,) new_short_op_2.uuid: tuple(meas for meas in required_syndromes) }
Note that for the repetition code, no stabilizer is partially measured and thus we do not have to keep track of any stabilizer in evolution or updates.
4.5.5. Examples for the surface code
Notation:
Standard code block describes a RotatedSurfaceCode instance for a single logical qubit where the logical operators are tied to the upper-left qubit of the code bock.
This means that there is exactly one PauliOperator in logical_x_operators and one in logical_z_operators.
Vertical logical describes the logical operator that spans the vertical (left) boundary of the rotated surface code block.
Short logical describes the logical operator that spans the horizontal (top) boundary of the rotated surface code block.
Required stabilizers describe the Stabilizer objects (sometimes by their UUID) that are required to go from one logical operator to the other.
They satisfy the relation: \(\{S_{\text{required}}\} = \{S_i | L' = L\prod_{i}S_i\}\) , where \(L\) and \(L'\) are respectively the initial and final logical operators.
Required syndromes describe the Syndrome objects corresponding to the required stabilizers measurements.
Grow is basically an identity operation, though one of the operators (the operator that is parallel to the grow direction – horizontal for left/right) will get longer.
This results in an increased distance for the other operator. The operation consists of resetting qubits in the grow direction (left/right/top/bottom) in the basis that matches the code stabilizers. For a grow to the right, the evolution of operators is:
# The horizontal operator gets longer evolution = { new_horizontal_op.uuid: (old_horizontal_op.uuid,), }
where
old_horizontal_opgrew intonew_horizontal_op. Nothing happens to the other (vertical) operator. Note that by default a grow towards the left does not displace the vertical logical operator to the left.Shrink can also be considered an identity operation, though we are measuring some data qubits and need to keep these into account in the update dictionaries. Consider a horizontal shrink that does not displace the vertical logical operator (e.g. from the right):
evolution = { new_horizontal_op.uuid: (old_horizontal_op.uuid,), } updates = { new_horizontal_op.uuid: tuple(meas for meas in shrink_measurements) }
where
shrink_measurementsare all the measurements on data qubits that are part ofold_horizontal_op.data_qubits. The new operatornew_horizontal_opalso inherits updates fromold_horizontal_op.If we shrink a surface code from the left, the vertical operator is displaced, resulting in:
evolution = { new_horizontal_op.uuid: (old_horizontal_op.uuid,) new_vertical_op.uuid: (old_vertical_op,) + tuple(stab.uuid for stab in stabs_required) } updates = { new_horizontal_op.uuid: tuple(meas for meas in shrink_measurements) new_vertical_op.uuid: tuple(meas for meas in required_syndromes) }
Merge is equivalent to a joint measurement of two blocks in the basis of the shared logical operator (aligned with the merge orientation). This is not a unitary operation because the number of logical operators after the operation is reduced. In the case of a horizontal merge, the final horizontal operator is made up of the two initial horizontal operators. In case operators are aligned already:
evolution = { new_horizontal_operator.uuid: (old_horizontal_op_1.uuid, old_horizontal_op_2.uuid,) }
The new (horizontal) operator will also inherit updates from the previous operators. Note that the resulting vertical operator will be inherited from the left-most (or top) block.
If the blocks are aligned but the operators to be merged (horizontal) are not, we first need to account for stabilizers required to align them. The stabilizers will bet tracked in the evolution and the syndromes in updates:
evolution = { new_long_op.uuid: (old_horizontal_op_1.uuid, old_horizontal_op_2.uuid,) + tuple(stab.uuid for stab in stabs_required) } updates = { new_horizontal_operator.uuid: tuple(meas for meas in required_syndromes) }
The vertical operators are not modified.
Split distributes the logical information in two blocks that are separated in space. This is done through physical qubits measurements. Split can be understood as the converse operation to merge. The measurements occurring during a split need to be kept track of to update the state of logical operators and stabilizers. In the case of a horizontal split, if no logical operator is measured out (
new_horizontal_op_1is the same asold_horizontal_op_1), we have:# Only one of the operators inherits updates from the initial operator evolution = { new_horizontal_op_1.uuid: (old_horizontal_op.uuid,) new_horizontal_op_2.uuid: (old_horizontal_op.uuid,) new_vertical_op_2.uuid: (old_vertical_op.uuid,) + tuple(stab.uuid for stab in stab_required) } # new_vertical_op_1 is inherited from the initial block updates = { new_long_op_1: (split_meas,) new_vertical_op_2.uuid: tuple(meas for meas in required_syndromes) }
If the initial vertical operator is fully measured by the split, both new vertical operators are displaced using stabilizers/syndromes.