from __future__ import annotations
import pathlib
from collections.abc import Callable, Collection, Mapping, Sequence
from copy import deepcopy
from typing import TypeVar
import stim
import yaml
from ..layouts.layout import Layout
T = TypeVar("T")
[docs]
class Detectors:
[docs]
def __init__(
self,
anc_qubits: Collection[str],
frame: str,
anc_coords: Mapping[str, Sequence[float | int]] | None = None,
include_gauge_dets: bool = False,
) -> None:
"""Initalises the ``Detectors`` class.
Parameters
----------
anc_qubits
List of ancilla qubits.
The detector ordering will follow this list.
frame
Detector frame to use when building the detectors.
The options for the detector frames are described in the Notes section.
anc_coords
Ancilla qubit coordinates that are added to the detectors if specified.
The coordinates of the detectors will be ``(*ancilla_coords[i], r)``,
with ``r`` the number of rounds (starting at 0).
include_gauge_dets
Flag to include or not the definition of gauge detectors.
By default, ``False``.
Notes
-----
Detector frame ``'post-gate'`` builds the detectors in the basis given by the
stabilizer generators of the last-measured QEC round.
Detector frame ``'pre-gate'`` builds the detectors in the basis given by the
stabilizer generators of the previous-last-measured QEC round.
Detector frame ``'gate-independent'`` builds the detectors as ``m_{a,r} ^ m_{a,r-1}``
independently of how the stabilizer generators have been transformed.
"""
if not isinstance(anc_qubits, Collection):
raise TypeError(
f"'anc_qubits' must be a Collection, but {type(anc_qubits)} was given."
)
if not isinstance(frame, str):
raise TypeError(f"'frame' must be a str, but {type(frame)} was given.")
if frame not in ["pre-gate", "post-gate", "gate-independent"]:
raise ValueError(
"'frame' must be 'pre-gate', 'post-gate', or 'gate-independent',"
f" but {frame} was given."
)
if anc_coords is None:
anc_coords = {a: [] for a in anc_qubits}
if not isinstance(anc_coords, dict):
raise TypeError(
f"'anc_coords' must be a dict, but {type(anc_coords)} was given."
)
if not (set(anc_coords) == set(anc_qubits)):
raise ValueError("'anc_coords' must have 'anc_qubits' as its keys.")
if any(not isinstance(c, Collection) for c in anc_coords.values()):
raise TypeError("Values in 'anc_coords' must be a collection.")
if len(set(len(c) for c in anc_coords.values())) != 1:
raise ValueError("Values in 'anc_coords' must have the same lenght.")
self.anc_qubit_labels: Collection[str] = anc_qubits
self.frame: str = frame
self.anc_coords: dict[str, Sequence[float | int]] = anc_coords
self.include_gauge_dets: bool = include_gauge_dets
self.new_circuit()
return
@classmethod
def from_layouts(
cls: type[Detectors],
*layouts: Layout,
frame: str = "pre-gate",
include_gauge_dets: bool = False,
) -> "Detectors":
"""Creates a ``Detectors`` object using the information from the layouts.
It loads all the ancilla qubits and their coordinates.
"""
anc_coords: dict[str, Sequence[float | int]] = {}
anc_qubits: list[str] = []
for layout in layouts:
anc_coords |= layout.anc_coords # updates dict
anc_qubits += layout.anc_qubits
return cls(
anc_qubits=anc_qubits,
frame=frame,
anc_coords=anc_coords,
include_gauge_dets=include_gauge_dets,
)
def new_circuit(self):
"""Resets all the current generators and number of rounds in order
to create a different circuit.
"""
self.detectors: dict[
str, set[str]
] = {} # {anc_label: propagation = set of ancilla labels}
self.num_rounds: dict[str, int] = {a: 0 for a in self.anc_qubit_labels}
self.total_num_rounds: int = 0
self.update_dict_list: list[dict[str, set[str]]] = []
self.gauge_detectors: set[str] = set()
return
def store_state(self, filename: str | pathlib.Path) -> None:
"""Stores the current state to the given YAML file.
This is useful in a conditioned circuit to not encode the first part
of the circuit for every realization."""
# convert sets to lists for easier storage in YAML file
state = {
"anc_qubit_labels": deepcopy(list(self.anc_qubit_labels)),
"frame": deepcopy(self.frame),
"anc_coords": deepcopy({k: list(v) for k, v in self.anc_coords.items()}),
"include_gauge_dets": deepcopy(self.include_gauge_dets),
"detectors": deepcopy({k: list(v) for k, v in self.detectors.items()}),
"num_rounds": deepcopy(self.num_rounds),
"total_num_rounds": deepcopy(self.total_num_rounds),
"update_dict_list": deepcopy(
[{k: list(v) for k, v in d.items()} for d in self.update_dict_list]
),
"gauge_detectors": deepcopy(list(self.gauge_detectors)),
}
with open(filename, "w") as file:
yaml.dump(state, file)
return
@classmethod
def load_state(cls, filename: str | pathlib.Path) -> "Detectors":
"""Loads the state inside the given YAML file.
See ``Model.store_state`` for more information."""
with open(filename, "r") as file:
state = yaml.safe_load(file)
detectors = cls(
anc_qubits=state["anc_qubit_labels"],
frame=state["frame"],
anc_coords=state["anc_coords"],
include_gauge_dets=state["include_gauge_dets"],
)
detectors.detectors = {k: set(v) for k, v in state["detectors"].items()}
detectors.num_rounds = state["num_rounds"]
detectors.total_num_rounds = state["total_num_rounds"]
detectors.update_dict_list = [
{k: set(v) for k, v in d.items()} for d in state["update_dict_list"]
]
detectors.gauge_detectors = set(state["gauge_detectors"])
return detectors
def activate_detectors(
self, anc_qubits: Collection[str], gauge_dets: Collection[str] | None = None
):
"""Activates the given ancilla detectors.
Parameters
----------
anc_qubits
List of ancilla detectors to activate.
gauge_dets
List of ancilla detectors that do not have a deterministic
outcome in their first QEC round. This is only important if
``include_gauge_dets = False`` was set when initializing this object.
"""
if not isinstance(anc_qubits, Collection):
raise TypeError(
f"'anc_qubits' must be an Collection, but {type(anc_qubits)} was given."
)
if set(anc_qubits) > set(self.anc_qubit_labels):
raise ValueError(
"Elements in 'anc_qubits' are not ancilla qubits in this object."
)
if len(anc_qubits) == 0:
return
if not set(anc_qubits).isdisjoint(self.detectors):
raise ValueError("Ancilla(s) were already active.")
if (gauge_dets is None) and (not self.include_gauge_dets):
raise ValueError(
"When not including gauge detectors, one must specify 'gauge_dets'."
)
if gauge_dets is None:
gauge_dets = []
if not isinstance(gauge_dets, Collection):
raise TypeError(
f"'gauge_dets' must be an Collection, but {type(gauge_dets)} was given."
)
if set(gauge_dets) > set(self.anc_qubit_labels):
raise ValueError(
"Elements in 'gauge_dets' are not ancilla qubits in this object."
)
if not set(gauge_dets).isdisjoint(self.gauge_detectors):
raise ValueError("Ancilla(s) were already set as gauge detectors.")
for anc in anc_qubits:
self.detectors[anc] = set([anc])
self.num_rounds[anc] = 0
self.gauge_detectors.update(gauge_dets)
return
def deactivate_detectors(self, anc_qubits: Collection[str]):
"""Deactivates the given ancilla detectors."""
if not isinstance(anc_qubits, Collection):
raise TypeError(
f"'anc_qubits' must be an Collection, but {type(anc_qubits)} was given."
)
if set(anc_qubits) > set(self.anc_qubit_labels):
raise ValueError(
"Elements in 'anc_qubits' are not ancilla qubits in this object."
)
for anc in anc_qubits:
exists = self.detectors.pop(anc, None)
if exists is None:
raise ValueError(f"Ancilla {anc} was already inactive.")
self.gauge_detectors.difference_update(anc_qubits)
return
def update(
self, new_stab_gens: dict[str, set[str]], new_stab_gens_inv: dict[str, set[str]]
) -> None:
"""Update the current stabilizer generators with the dictionary
descriving the effect of the logical gate. It allows to perform
more than one logical gate between QEC rounds.
See module ``surface_sim.log_gates`` to see how to prepare
the layout to run logical gates.
Note that it does not really update the stabilizer generators but it
stores the change. They are only updated when calling ``build_from_anc``
and ``build_from_data`` functions due to the ``"post-gate"`` frame.
This behavior is due to the ``"post-gate"`` frame.
Parameters
----------
new_stab_gens
Dictionary that maps ancilla qubits (representing the stabilizer
generators) to a list of ancilla qubits (representing the decomposition
of propagated stabilizer generators through the logical gate
in terms of the stabilizer generators).
If the dictionary is missing ancillas, their stabilizer generators
are assumed to not be transformed by the logical gate.
See ``get_new_stab_dict_from_layout`` for more information.
For example, ``{"X1": ["X1", "Z1"]}`` is interpreted as that the
logical gate has transformed ``X1`` to ``X1*Z1``.
new_stab_gens_inv:
Same as ``new_stab_gens`` for the logical gate inverse.
Notes
-----
The ``new_stab_gens`` dictionary (or equivalently matrix) can be calculated by
.. math::
S'_i = U_L^\\dagger S_i U_L
with :math:`U_L` the logical gate and :math:`S_i` (:math:`S'_i`) the
stabilizer generator :math:`i` before (after) the logical gate.
From `this reference <https://arthurpesah.me/blog/2023-03-16-stabilizer-formalism-2/>`_.
The ``new_stab_gens_inv`` dictionary can be calculated by
.. math::
S'_i = U_L S_i U_L^\\dagger
"""
if self.frame == "gate-independent":
return
elif self.frame == "pre-gate":
# make a copy because the dict is modified on place later on
update_dict = deepcopy(new_stab_gens)
elif self.frame == "post-gate":
# make a copy because the dict is modified on place later on
update_dict = deepcopy(new_stab_gens_inv)
if not isinstance(update_dict, dict):
raise TypeError(
"'new_stab_gens' and 'new_stab_gens_inv' must be a dict, "
f"but {type(update_dict)} was given."
)
if any(not isinstance(s, set) for s in update_dict.values()):
raise TypeError(
"Elements in 'new_stab_gens' and 'new_stab_gens_inv' must be sets."
)
if set(update_dict) > set(self.anc_qubit_labels):
raise ValueError(
"Elements in 'new_stab_gens' and 'new_stabs_gens_inv' are not "
"ancilla qubits in this Detectors class."
)
for anc, propagation in update_dict.items():
# this is useful for updating the self.detector progations
propagation.symmetric_difference_update([anc])
if self.frame == "pre-gate":
self.update_dict_list.append(update_dict) # insert at the end
elif self.frame == "post-gate":
self.update_dict_list.insert(0, update_dict) # insert at beginning
return
def build_from_anc(
self,
get_rec: Callable[[str, int], stim.GateTarget],
anc_reset: bool,
anc_qubits: Collection[str] | None = None,
) -> stim.Circuit:
"""Returns the stim circuit with the corresponding detectors
given that the ancilla qubits have been measured.
Parameters
----------
get_rec
Function that given ``qubit_label, rel_meas_id`` returns the
corresponding ``stim.target_rec``. The intention is to give the
``Model.meas_target`` method.
anc_reset
Flag for if the ancillas are being reset in every QEC round.
anc_qubits
List of the ancilla qubits for which to build the detectors.
By default, builds all the detectors.
Returns
-------
detectors_stim
Detectors defined in a ``stim`` circuit.
Notes
-----
This function assumes that all QEC rounds happen at the same time
for all logical qubits. It is not possible to have some qubits
performing some logical gates and some qubits performing QEC rounds.
This is because the dicts for updating the qubits are stored globally,
not per ancilla qubit.
"""
if anc_qubits is None:
# use only active detectors
anc_qubits = list(self.detectors)
if not isinstance(anc_qubits, Collection):
raise TypeError(
f"'anc_qubits' must be an Collection or None, but {type(anc_qubits)} was given."
)
if not isinstance(get_rec, Callable):
raise TypeError(
f"'get_rec' must be callable, but {type(get_rec)} was given."
)
# remove any inactive detector that was given, this is caused by the
# arbitrary logical circuit generator becuase if we have M 0 I 1 TICK
# the 'TICK'/QEC round will try to build the detectors for logical qubit 0,
# which have been deactivated by the measurement.
anc_qubits = [q for q in anc_qubits if q in self.detectors]
if not self.include_gauge_dets:
anc_qubits = [q for q in anc_qubits if q not in self.gauge_detectors]
anc_qubits = set(anc_qubits)
self.total_num_rounds += 1
for anc in self.detectors:
self.num_rounds[anc] += 1
if self.frame == "gate-independent":
# build the detectors
meas_comp = -2 if anc_reset else -3
detectors = {}
for anc in anc_qubits:
dets = [(anc, -1)]
if meas_comp + self.num_rounds[anc] >= 0:
dets.append((anc, meas_comp))
detectors[anc] = dets
else:
# update the detectors given the logical gates
for update_dict in self.update_dict_list:
for propagation in self.detectors.values():
for q in deepcopy(propagation):
# as the detectors are updated PER LOGICAL GATE,
# the stabilizers of the 2nd logical qubit are not
# in the update list of the 1st logical qubit if the
# gate is not a two-qubit gate
if q in update_dict:
propagation.symmetric_difference_update(update_dict[q])
# build the detectors
detectors: dict[str, list[tuple[str, int]]] = {}
anc_reset_curr, anc_reset_prev = anc_reset, anc_reset
for anc_qubit, (p_gen, c_gen) in zip(
self.detectors, self.detectors.items()
):
p_gen = set([p_gen]) # p_gen is just the label of the ancilla
if self.frame == "post-gate":
c_gen, p_gen = p_gen, c_gen
targets = [(q, -1) for q in c_gen]
if self.num_rounds[anc_qubit] >= 2:
targets += [(q, -2) for q in p_gen]
if not anc_reset_curr and self.num_rounds[anc_qubit] >= 2:
targets += [(q, -2) for q in c_gen]
if not anc_reset_prev and self.num_rounds[anc_qubit] >= 3:
targets += [(q, -3) for q in p_gen]
detectors[anc_qubit] = targets
# build the stim circuit
# the detectors are built in the same ordering as 'self.anc_qubit_labels' to
# make it reproducible and so that the user can choose it.
detectors_stim = stim.Circuit()
for anc in self.anc_qubit_labels:
if anc in anc_qubits:
# simplify the expression of the detectors by removing the pairs
targets = detectors[anc]
targets = remove_pairs(targets)
detectors_rec = [get_rec(*t) for t in targets]
else:
# create the detector but make it be always 0
detectors_rec = []
coords = [*self.anc_coords[anc], self.total_num_rounds - 1]
instr = stim.CircuitInstruction(
"DETECTOR", gate_args=coords, targets=detectors_rec
)
detectors_stim.append(instr)
# reset detectors and update_dict list
self.detectors = {q: set([q]) for q in self.detectors}
self.update_dict_list = []
self.gauge_detectors = set()
return detectors_stim
def build_from_data(
self,
get_rec: Callable[[str, int], stim.GateTarget],
anc_support: Mapping[str, Collection[str]],
anc_reset: bool,
reconstructable_stabs: Collection[str],
anc_qubits: Collection[str] | None = None,
) -> stim.Circuit:
"""Returns the stim circuit with the corresponding detectors
given that the data qubits have been measured.
Note that the detectors for the ``"pre-gate"`` and ``"post-gate"``
frames are both constructed in the ``"post-gate"`` frame! See section Notes
for more explanation.
Parameters
----------
get_rec
Function that given ``qubit_label, rel_meas_id`` returns the
``target_rec`` integer. The intention is to give the
``Model.meas_target`` method.
anc_support
Dictionary descriving the data qubit support on the stabilizers.
The keys are the ancilla qubits and the values are the collection
of data qubits.
See ``surface_sim.Layout.get_support`` for more information.
anc_reset
Flag for if the ancillas are being reset in every QEC round.
reconstructable_stabs
Stabilizers that can be reconstructed from the data qubit outcomes.
anc_qubits
List of the ancilla qubits for which to build the detectors.
By default, builds all the detectors.
Returns
-------
detectors_stim
Detectors defined in a ``stim`` circuit.
Notes
-----
The reason that the detectors in the ``"pre-gate"`` frame are built in
the ``"post-gate"`` frame is that there can be situations in which the
detectors cannot be built in the ``"pre-gate"`` frame. For example,
R 0 1
TICK
CX 0 1
M 0
As there is no QEC round performed in (logical) qubit 1 and the stabilizer
generators of qubit 0 are propagated to qubit 1, we cannot build the
detectors in the ``"pre-gate"`` frame.
Note that if one always performs (at least) one QEC round after each logical
gate, then there is no difference in building the detectors for the
measurement in the ``"pre-gate"`` or in the ``"post-gate"`` frame
as one will always have:
TICK
M 0
"""
if not isinstance(reconstructable_stabs, Collection):
raise TypeError(
"'reconstructable_stabs' must be iterable, "
f"but {type(reconstructable_stabs)} was given."
)
if anc_qubits is None:
# use only active detectors
anc_qubits = list(self.detectors)
if not isinstance(anc_qubits, Collection):
raise TypeError(
f"'anc_qubits' must be iterable or None, but {type(anc_qubits)} was given."
)
if not isinstance(get_rec, Callable):
raise TypeError(
f"'get_rec' must be callable, but {type(get_rec)} was given."
)
if not isinstance(anc_support, dict):
raise TypeError(
f"'anc_support' must be a dict, but {type(anc_support)} was given."
)
if set(anc_support) < set(reconstructable_stabs):
raise ValueError(
"Elements in 'reconstructable_stabs' must be present in 'anc_support'."
)
# for a logical measurement, one always needs to build the Z- or X-type
# detectors (depending on the logical measurement basis). One should not
# try to build any other type of detectors as it is not possible (because
# we have only measured the data qubits in an specific basis), so we
# do not have access to all stabilizers.
reconstructable_stabs = set(reconstructable_stabs)
anc_qubits = [q for q in anc_qubits if q in reconstructable_stabs]
if not self.include_gauge_dets:
anc_qubits = [q for q in anc_qubits if q not in self.gauge_detectors]
# Logical measurement is not considered a QEC round but a logical operation.
# therefore, it does not increase the number of rounds.
# However, the way building the detectors is implemented relies on
# faking that ancilla qubits have been measured instead of the data qubits.
fake_num_rounds = deepcopy(self.num_rounds)
for anc in self.detectors:
fake_num_rounds[anc] += 1
if self.frame == "gate-independent":
anc_detectors = {}
for anc in anc_qubits:
dets = [(anc, -1)]
if fake_num_rounds[anc] > 1:
dets.append((anc, -2))
if (not anc_reset) and (fake_num_rounds[anc] > 2):
dets.append((anc, -3))
anc_detectors[anc] = dets
else:
# always use the "post-gate" frame
if self.frame == "pre-gate":
self.update_dict_list.reverse()
# update the detectors given the logical gates
for update_dict in self.update_dict_list:
for propagation in self.detectors.values():
for q in deepcopy(propagation):
# as the detectors are updated PER LOGICAL GATE,
# the stabilizers of the 2nd logical qubit are not
# in the update list of the 1st logical qubit if the
# gate is not a two-qubit gate
if q in update_dict:
propagation.symmetric_difference_update(update_dict[q])
# build the detectors
anc_detectors: dict[str, list[tuple[str, int]]] = {}
anc_reset_curr, anc_reset_prev = True, anc_reset
for anc_qubit, (p_gen, c_gen) in zip(
self.detectors, self.detectors.items()
):
p_gen = set([p_gen]) # p_gen is just the label of the ancilla
# always use the "post-gate" frame
c_gen, p_gen = p_gen, c_gen
targets = [(q, -1) for q in c_gen]
if fake_num_rounds[anc_qubit] >= 2:
targets += [(q, -2) for q in p_gen]
if not anc_reset_curr and fake_num_rounds[anc_qubit] >= 2:
targets += [(q, -2) for q in c_gen]
if not anc_reset_prev and fake_num_rounds[anc_qubit] >= 3:
targets += [(q, -3) for q in p_gen]
anc_detectors[anc_qubit] = targets
# udpate the (anc, -1) to a the corresponding set of (data, -1)
detectors: dict[str, list[tuple[str, int]]] = {}
for anc_qubit in anc_qubits:
dets = anc_detectors[anc_qubit]
new_dets: list[tuple[str, int]] = []
for det in dets:
if det[1] != -1:
# rel_meas need to be updated because the ancillas have not
# been measured in the last round, only the data qubits
# e.g. ("X1", -2) should be ("X1", -1)
det = (det[0], det[1] + 1)
new_dets.append(det)
continue
new_dets += [(q, -1) for q in anc_support[det[0]]]
detectors[anc_qubit] = new_dets
# build the stim circuit
# the detectors are built in the same ordering as 'self.anc_qubit_labels' to
# make it reproducible and so that the user can choose it.
# Contrary to 'build_from_anc' we do not add empty detectors because
# only one (logical) qubit is measured, so we don't need to report the stabilizers
# of other (logical) qubits.
detectors_stim = stim.Circuit()
for anc in reconstructable_stabs:
if anc in anc_qubits:
# simplify the expression of the detectors by removing the pairs
targets = detectors[anc]
targets = remove_pairs(targets)
detectors_rec = [get_rec(*t) for t in targets]
else:
# create the detector but make it be always 0
detectors_rec = []
coords = [*self.anc_coords[anc], self.total_num_rounds - 0.5]
instr = stim.CircuitInstruction(
"DETECTOR", gate_args=coords, targets=detectors_rec
)
detectors_stim.append(instr)
# reset detectors, but the update_dict_list is not updated
# as there could qubits performing the QEC round that have undergone
# some logical gates. However, it needs to be reversed to counteract
# the reverse suffered during this function (only for the pre-gate frame).
self.detectors = {q: set([q]) for q in self.detectors}
if self.frame == "pre-gate":
self.update_dict_list.reverse()
return detectors_stim
[docs]
def get_new_stab_dict_from_layout(
layout: Layout, log_gate: str
) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
"""Returns a dictionary that describes the stabilizer generator transformation
due to the given logical gate.
For example, the output ``{"X1": ["X1", "Z1"]}`` is interpreted as that the
logical gate has transformed X1 to X1*Z1.
Parameters
----------
layout
Layout that has information about the ``log_gate``.
log_gate
Name of the logical gate.
Returns
-------
new_stab_gens
Dictionary that maps ancilla qubits (representing the new stabilizer
generators) to a list of ancilla qubits (representing the old
stabilizer generators).
If the dictionary is missing ancillas, their stabilizer generators
are assumed to not be transformed by the logical gate.
new_stab_gens_inv
Same as ``new_stab_gens`` but for the gate inverse.
"""
if not isinstance(layout, Layout):
raise TypeError(f"'layout' must be a Layout, but {type(layout)} was given.")
if not isinstance(log_gate, str):
raise TypeError(f"'log_gate' must be a str, but {type(log_gate)} was given.")
anc_qubits = layout.anc_qubits
new_stab_gens = {}
new_stab_gens_inv = {}
for anc_qubit in anc_qubits:
log_gate_attrs = layout.param(log_gate, anc_qubit)
if log_gate_attrs is None:
raise ValueError(
f"New stabilizer generators for {log_gate} "
f"are not specified for qubit {anc_qubit}."
"They should be setted with 'surface_sim.log_gates'."
)
new_stab_gens[anc_qubit] = set(log_gate_attrs["new_stab_gen"])
new_stab_gens_inv[anc_qubit] = set(log_gate_attrs["new_stab_gen_inv"])
return new_stab_gens, new_stab_gens_inv
def remove_pairs(elements: list[T]) -> list[T]:
"""Removes all possible pairs inside the given list."""
output: list[T] = []
for element in elements:
if elements.count(element) % 2 == 1:
output.append(element)
return output