"""QEC Code object."""
from __future__ import annotations
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, NamedTuple
from scipy.sparse import csr_array
from graphqomb.common import Axis, AxisMeasBasis, Sign
from graphqomb.graphstate import GraphState
if TYPE_CHECKING:
from collections.abc import Mapping
_TYPE_II_CHAIN_LENGTH = 3
Coordinate = tuple[float, ...]
[docs]
class YFoliation(Enum):
"""Y-foliation graph-state builder variant."""
TYPE_I = auto()
TYPE_II = auto()
[docs]
class StabilizerCode:
"""A stabilizer code."""
[docs]
def __init__(
self,
stabilizer_matrix: csr_array[Any, tuple[int, int]],
*,
stabilizer_coords: Mapping[int, Coordinate] | None = None,
qubit_coords: Mapping[int, Coordinate] | None = None,
) -> None:
self.hx = csr_array(stabilizer_matrix[:, : stabilizer_matrix.shape[1] // 2])
self.hz = csr_array(stabilizer_matrix[:, stabilizer_matrix.shape[1] // 2 :])
self.stabilizer_coord = stabilizer_coords
self.qubit_coord = qubit_coords
@property
def num_stabilizers(self) -> int:
"""Number of stabilizers."""
return int(self.hx.shape[0])
@property
def num_qubits(self) -> int:
"""Number of qubits."""
return int(self.hx.shape[1])
[docs]
class StabilizerGraphStateBuildResult(NamedTuple):
"""Result of building a graph state from a stabilizer code.
``graph`` is the constructed graph state with data and ancilla nodes,
edges, coordinates, and measurement bases. ``data_nodes`` maps each
``(physical_qubit, data_layer)`` pair to its graph node id.
``ancilla_nodes`` maps each stabilizer row index to its ancilla graph node
id.
"""
graph: GraphState
data_nodes: dict[tuple[int, int], int]
ancilla_nodes: dict[int, int]
class _DataLayerPlan(NamedTuple):
"""Data-node layer layout for graph-state construction."""
data_layers: dict[int, tuple[int, ...]]
measurement_layers: dict[int, tuple[int, ...]]
coordinate_z_by_layer: dict[tuple[int, int], float]
meas_basis_by_qubit: dict[int, AxisMeasBasis]
y_foliation: YFoliation
data_as_io: bool
qubit_indices: Mapping[int, int] | None
class _StabilizerSupport(NamedTuple):
"""Sparse support sets for one stabilizer row."""
hx: set[int]
hz: set[int]
[docs]
def build_graph_state(
code: StabilizerCode,
z_base: int = 0,
*,
y_foliation: YFoliation = YFoliation.TYPE_I,
data_as_io: bool = False,
qubit_indices: Mapping[int, int] | None = None,
) -> StabilizerGraphStateBuildResult:
"""Build a graph-state unit from a stabilizer code.
Parameters
----------
code : `StabilizerCode`
Stabilizer code to convert. The X support is connected to the upper
data layer and the Z support is connected to the lower data layer.
z_base : `int`, optional
Lower stabilizer-measurement data-layer index, by default 0. Type I
uses two measurement layers; Type II uses three for Y support. When
``data_as_io`` is enabled, a separate output layer is appended.
y_foliation : `YFoliation`, optional
Foliation variant. Type II uses a three-node Y-measured data chain only
for qubits that have an Hx=Hz=1 support in at least one stabilizer row.
data_as_io : `bool`, optional
Whether to register the first stabilizer-measurement data nodes as
inputs and append separate unmeasured output nodes, by default False.
qubit_indices : collections.abc.Mapping[int, int] | None, optional
Mapping from stabilizer-code qubit columns to graph qindices when
``data_as_io`` is enabled. If omitted, code qubit columns are used.
Returns
-------
`StabilizerGraphStateBuildResult`
Graph state and maps from stabilizer/data indices to graph nodes.
Raises
------
TypeError
If z_base is not an integer.
ValueError
If ``qubit_indices`` is invalid for the requested data I/O layout.
"""
if not isinstance(z_base, int):
msg = "z_base must be an integer."
raise TypeError(msg)
if qubit_indices is not None:
if not data_as_io:
msg = "qubit_indices can only be used when data_as_io=True."
raise ValueError(msg)
expected_qubits = set(range(code.num_qubits))
provided_qubits = set(qubit_indices)
if provided_qubits != expected_qubits:
missing = sorted(expected_qubits - provided_qubits)
unexpected = sorted(provided_qubits - expected_qubits)
msg = (
"qubit_indices must map every stabilizer-code qubit exactly once; "
f"missing={missing}, unexpected={unexpected}."
)
raise ValueError(msg)
qindices = list(qubit_indices.values())
if len(qindices) != len(set(qindices)):
msg = "qubit_indices values must be unique."
raise ValueError(msg)
graph = GraphState()
x_meas_basis = AxisMeasBasis(Axis.X, Sign.PLUS)
data_layer_plan = _data_layer_plan(
code,
z_base=z_base,
y_foliation=y_foliation,
data_as_io=data_as_io,
qubit_indices=qubit_indices,
)
data_nodes = _add_layered_data_nodes(graph, code, data_layer_plan)
ancilla_nodes = _add_ancilla_nodes(graph, code, data_nodes, data_layer_plan, x_meas_basis)
return StabilizerGraphStateBuildResult(graph, data_nodes, ancilla_nodes)
def _data_layer_plan(
code: StabilizerCode,
*,
z_base: int,
y_foliation: YFoliation,
data_as_io: bool,
qubit_indices: Mapping[int, int] | None,
) -> _DataLayerPlan:
data_layers: dict[int, tuple[int, ...]] = {}
measurement_layers: dict[int, tuple[int, ...]] = {}
coordinate_z_by_layer: dict[tuple[int, int], float] = {}
meas_basis_by_qubit: dict[int, AxisMeasBasis] = {}
x_meas_basis = AxisMeasBasis(Axis.X, Sign.PLUS)
y_meas_basis = AxisMeasBasis(Axis.Y, Sign.PLUS)
y_chain_qubits: set[int] = _qubits_with_y_support(code) if y_foliation is YFoliation.TYPE_II else set()
for qubit in range(code.num_qubits):
measured_layers: tuple[int, ...]
measured_coordinate_zs: tuple[float, ...]
if qubit in y_chain_qubits:
measured_layers = (z_base, z_base + 1, z_base + 2)
measured_coordinate_zs = (
float(z_base),
float(z_base) + 0.5,
float(z_base + 1),
)
meas_basis = y_meas_basis
else:
measured_layers = (z_base, z_base + 1)
measured_coordinate_zs = (float(z_base), float(z_base + 1))
meas_basis = x_meas_basis
if data_as_io:
layers = (*measured_layers, measured_layers[-1] + 1)
coordinate_zs = (*measured_coordinate_zs, float(z_base + 2))
else:
layers = measured_layers
coordinate_zs = measured_coordinate_zs
data_layers[qubit] = layers
measurement_layers[qubit] = measured_layers
meas_basis_by_qubit[qubit] = meas_basis
for layer, coordinate_z in zip(layers, coordinate_zs, strict=True):
coordinate_z_by_layer[qubit, layer] = coordinate_z
return _DataLayerPlan(
data_layers=data_layers,
measurement_layers=measurement_layers,
coordinate_z_by_layer=coordinate_z_by_layer,
meas_basis_by_qubit=meas_basis_by_qubit,
y_foliation=y_foliation,
data_as_io=data_as_io,
qubit_indices=qubit_indices,
)
def _add_layered_data_nodes(
graph: GraphState,
code: StabilizerCode,
data_layer_plan: _DataLayerPlan,
) -> dict[tuple[int, int], int]:
"""Add layered data nodes.
Returns
-------
`dict`[`tuple`[`int`, `int`], `int`]
Mapping from physical qubit and z layer to graph node.
"""
data_nodes: dict[tuple[int, int], int] = {}
for qubit in range(code.num_qubits):
previous_node: int | None = None
layers = data_layer_plan.data_layers[qubit]
measured_layers = set(data_layer_plan.measurement_layers[qubit])
for layer in layers:
node = graph.add_node(
coordinate=_data_coordinate(code, qubit, data_layer_plan.coordinate_z_by_layer[qubit, layer])
)
if previous_node is not None:
graph.add_edge(previous_node, node)
if layer in measured_layers:
graph.assign_meas_basis(node, data_layer_plan.meas_basis_by_qubit[qubit])
data_nodes[qubit, layer] = node
previous_node = node
if data_layer_plan.data_as_io:
q_index = data_layer_plan.qubit_indices[qubit] if data_layer_plan.qubit_indices is not None else qubit
graph.register_input(data_nodes[qubit, data_layer_plan.measurement_layers[qubit][0]], q_index)
graph.register_output(data_nodes[qubit, layers[-1]], q_index)
return data_nodes
def _add_ancilla_nodes(
graph: GraphState,
code: StabilizerCode,
data_nodes: dict[tuple[int, int], int],
data_layer_plan: _DataLayerPlan,
meas_basis: AxisMeasBasis,
) -> dict[int, int]:
"""Add ancilla nodes and stabilizer-support edges.
Returns
-------
`dict`[`int`, `int`]
Mapping from stabilizer row index to graph node.
"""
ancilla_nodes: dict[int, int] = {}
hx = code.hx.copy()
hz = code.hz.copy()
hx.eliminate_zeros()
hz.eliminate_zeros()
for stabilizer in range(code.num_stabilizers):
explicit_ancilla_coord = _explicit_ancilla_coordinate(code, stabilizer)
ancilla_node = graph.add_node(coordinate=explicit_ancilla_coord)
graph.assign_meas_basis(ancilla_node, meas_basis)
ancilla_nodes[stabilizer] = ancilla_node
connected_data_nodes = _connect_stabilizer_support(
graph,
ancilla_node=ancilla_node,
support=_StabilizerSupport(
hx=set(_row_support(hx, stabilizer)),
hz=set(_row_support(hz, stabilizer)),
),
data_nodes=data_nodes,
data_layer_plan=data_layer_plan,
)
if explicit_ancilla_coord is None:
inferred_coord = _average_node_coordinates(graph, connected_data_nodes)
if inferred_coord is not None:
graph.set_coordinate(ancilla_node, inferred_coord)
return ancilla_nodes
def _connect_stabilizer_support(
graph: GraphState,
*,
ancilla_node: int,
support: _StabilizerSupport,
data_nodes: dict[tuple[int, int], int],
data_layer_plan: _DataLayerPlan,
) -> list[int]:
connected_data_nodes: list[int] = []
for qubit in sorted(support.hx | support.hz):
layers = data_layer_plan.measurement_layers[qubit]
if data_layer_plan.y_foliation is YFoliation.TYPE_II and len(layers) == _TYPE_II_CHAIN_LENGTH:
layer = _type_ii_support_layer(layers, has_x=qubit in support.hx, has_z=qubit in support.hz)
data_node = data_nodes[qubit, layer]
graph.add_edge(ancilla_node, data_node)
connected_data_nodes.append(data_node)
continue
if qubit in support.hz:
data_node = data_nodes[qubit, layers[0]]
graph.add_edge(ancilla_node, data_node)
connected_data_nodes.append(data_node)
if qubit in support.hx:
data_node = data_nodes[qubit, layers[-1]]
graph.add_edge(ancilla_node, data_node)
connected_data_nodes.append(data_node)
return connected_data_nodes
def _type_ii_support_layer(layers: tuple[int, ...], *, has_x: bool, has_z: bool) -> int:
if has_z and not has_x:
return layers[0]
if has_z and has_x:
return layers[1]
return layers[2]
def _qubits_with_y_support(code: StabilizerCode) -> set[int]:
hx = code.hx.copy()
hz = code.hz.copy()
hx.eliminate_zeros()
hz.eliminate_zeros()
y_qubits: set[int] = set()
for stabilizer in range(code.num_stabilizers):
y_qubits.update(set(_row_support(hx, stabilizer)) & set(_row_support(hz, stabilizer)))
return y_qubits
def _data_coordinate(code: StabilizerCode, qubit: int, z: float) -> tuple[float, float, float] | None:
"""Return the 3D coordinate of a layered data node.
Returns
-------
`tuple`[`float`, `float`, `float`] | `None`
Lifted 3D coordinate, or None when the qubit has no coordinate.
"""
if code.qubit_coord is None or qubit not in code.qubit_coord:
return None
coord = code.qubit_coord[qubit]
return (float(coord[0]), float(coord[1]), float(z))
def _explicit_ancilla_coordinate(code: StabilizerCode, stabilizer: int) -> tuple[float, float, float] | None:
"""Return an explicitly supplied ancilla coordinate, if present.
Returns
-------
`tuple`[`float`, `float`, `float`] | `None`
Explicit 3D coordinate, or None when no coordinate is supplied.
"""
if code.stabilizer_coord is None or stabilizer not in code.stabilizer_coord:
return None
coord = code.stabilizer_coord[stabilizer]
return (float(coord[0]), float(coord[1]), float(coord[2]))
def _row_support(matrix: csr_array, row: int) -> list[int]:
"""Return nonzero column indices in a CSR sparse row.
Returns
-------
`list`[`int`]
Nonzero column indices for the row.
"""
start = int(matrix.indptr[row])
end = int(matrix.indptr[row + 1])
return [int(col) for col in matrix.indices[start:end]]
def _average_node_coordinates(graph: GraphState, nodes: list[int]) -> tuple[float, float, float] | None:
"""Return the componentwise average of node coordinates when all are available.
Returns
-------
`tuple`[`float`, `float`, `float`] | `None`
Average coordinate, or None when no average can be inferred.
"""
if not nodes:
return None
coordinates = graph.coordinates
if any(node not in coordinates for node in nodes):
return None
return (
sum(coordinates[node][0] for node in nodes) / len(nodes),
sum(coordinates[node][1] for node in nodes) / len(nodes),
sum(coordinates[node][2] for node in nodes) / len(nodes),
)