Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 48 additions & 16 deletions src/tqecd/boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@

from tqecd.exceptions import TQECDException
from tqecd.measurement import RelativeMeasurementLocation
from tqecd.pauli import PauliString
from tqecd.pauli import CollapsingOperators, PauliString


class BoundaryStabilizer:
def __init__(
self,
stabilizer: PauliString,
collapsing_operations: Iterable[PauliString],
collapsing_operations: Iterable[PauliString] | CollapsingOperators,
measurements: list[RelativeMeasurementLocation],
reset_qubits: frozenset[int],
forward: bool,
Expand All @@ -29,6 +29,10 @@ def __init__(
operation has been applied.
collapsing_operations: The collapsing operations the stabilizer will
have to go through to exit the :class:`~tqecd.fragment.Fragment`.
Either the individual single-qubit operators, or an already-built
:class:`~tqecd.pauli.CollapsingOperators` (used by
:meth:`merge`, :meth:`with_measurement_offset` and fragment-level
construction to share the precomputed masks).
measurements: measurement offsets relative to the **end** of the
fragment (even if the created :class:`BoundaryStabilizer`
instance represents a stabilizer on the beginning boundary) of
Expand All @@ -44,12 +48,24 @@ def __init__(
"""
self._stabilizer = stabilizer
self._measurements = measurements
self._collapsing_operations = frozenset(collapsing_operations)
self._has_anticommuting_collapsing_operations = any(
co.anticommutes(stabilizer) for co in collapsing_operations
self._collapse = (
collapsing_operations
if isinstance(collapsing_operations, CollapsingOperators)
else CollapsingOperators.from_paulis(collapsing_operations)
)
# The individual operators are reconstructed from the masks only on
# demand (repr, external access); see ``collapsing_operations``.
self._collapsing_operations_cache: frozenset[PauliString] | None = None
self._has_anticommuting_collapsing_operations = (
self._collapse.anticommutes_with(stabilizer)
)
self._reset_qubits: frozenset[int] = reset_qubits
self._is_forward = forward
# Lazily-cached return value of ``after_collapse``. ``BoundaryStabilizer``
# is immutable after construction, so the collapsed stabilizer is
# computed at most once and reused (it is accessed repeatedly while
# matching detectors).
self._after_collapse: PauliString | None = None

@property
def has_anticommuting_operations(self) -> bool:
Expand All @@ -67,6 +83,10 @@ def after_collapse(self) -> PauliString:
"""Compute the stabilizer obtained after applying the collapsing
operations.

The result is computed once on first access and cached for subsequent
accesses, which is safe because ``BoundaryStabilizer`` is immutable
after construction.

Raises:
TQECDException: If any of the collapsing operation anti-commutes
with the stored stabilizer.
Expand All @@ -80,7 +100,9 @@ def after_collapse(self) -> PauliString:
"Cannot collapse a BoundaryStabilizer if it has "
"anticommuting operations."
)
return self._stabilizer.collapse_by(self._collapsing_operations)
if self._after_collapse is None:
self._after_collapse = self._collapse.collapse(self._stabilizer)
return self._after_collapse

@property
def before_collapse(self) -> PauliString:
Expand All @@ -96,9 +118,21 @@ def before_collapse(self) -> PauliString:

@property
def collapsing_operations(self) -> Iterable[PauliString]:
"""Iterator on all the collapsing operations defining the boundary this
stabilizer is applied to."""
return self._collapsing_operations
"""All the collapsing operations defining the boundary this stabilizer is
applied to.

The single-qubit operations are reconstructed (and cached) from the
stored ``(X, Z)`` masks on first access.
"""
if self._collapsing_operations_cache is None:
self._collapsing_operations_cache = self._collapse.to_paulis()
return self._collapsing_operations_cache

@property
def collapse(self) -> CollapsingOperators:
"""The collapsing operators defining the boundary this stabilizer is
applied to."""
return self._collapse

@property
def measurements(self) -> list[RelativeMeasurementLocation]:
Expand Down Expand Up @@ -127,14 +161,12 @@ def merge(self, other: BoundaryStabilizer) -> BoundaryStabilizer:
operations (i.e., the same boundary), but with the two pre-collapsing
stabilizers multiplied together.
"""
self_collapsing_operations = set(self.collapsing_operations)
other_collapsing_operations = set(other.collapsing_operations)
if self_collapsing_operations != other_collapsing_operations:
if self.collapse != other.collapse:
raise TQECDException(
"Breaking pre-condition: trying to merge two BoundaryStabilizer "
"instances that are not defined on the same boundary.\n"
f"Collapsing operations for left-hand side: {self_collapsing_operations}.\n"
f"Collapsing operations for right-hand side: {other_collapsing_operations}.\n"
f"Collapsing operations for left-hand side: {set(self.collapsing_operations)}.\n"
f"Collapsing operations for right-hand side: {set(other.collapsing_operations)}.\n"
)
if self._is_forward != other._is_forward:
raise TQECDException(
Expand Down Expand Up @@ -179,7 +211,7 @@ def merge(self, other: BoundaryStabilizer) -> BoundaryStabilizer:
)
return BoundaryStabilizer(
stabilizer,
self_collapsing_operations,
self.collapse,
measurements,
reset_qubits,
is_forward_merge,
Expand Down Expand Up @@ -227,7 +259,7 @@ def coordinates(
def with_measurement_offset(self, offset: int) -> BoundaryStabilizer:
return BoundaryStabilizer(
self._stabilizer,
self.collapsing_operations,
self.collapse,
[m.offset_by(offset) for m in self.measurements],
self._reset_qubits,
self._is_forward,
Expand Down
13 changes: 13 additions & 0 deletions src/tqecd/boundary_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ def test_after_and_before_collapse() -> None:
assert stab.after_collapse == X0Z1


def test_after_collapse_is_cached() -> None:
X0Z1 = PauliString({0: "X", 1: "Z"})
X0 = PauliString({0: "X"})
Z1 = PauliString({1: "Z"})

stab = BoundaryStabilizer(X0Z1, [X0, Z1], [], frozenset([0]), True)
first = stab.after_collapse
second = stab.after_collapse
assert first == PauliString({})
# The collapsed stabilizer is computed once and reused on later accesses.
assert first is second


def test_merge() -> None:
X0Z1 = PauliString({0: "X", 1: "Z"})
Z0Z1 = PauliString({0: "Z", 1: "Z"})
Expand Down
100 changes: 36 additions & 64 deletions src/tqecd/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,6 @@
from tqecd.pauli import PauliString


def _find_cover(
target: PauliString,
sources: list[PauliString],
on_qubits: frozenset[int],
commute_with: PauliString | None = None,
) -> list[int] | None:
"""Try to find a set of boundary stabilizers from ``sources`` that generate
``target`` (or commute with ``target``) on qubits ``on_qubits`` (a "cover").

If multiple valid covers exist, only one will be returned. This choice is
deterministic, but not necessarily with the lowest weight.

Args:
target: the stabilizers to cover with stabilizers from ``sources``.
sources: stabilizers that can be used to cover ``target``.
on_qubits: qubits to consider when trying to cover ``target`` with
``sources``.
commute_with: if provided, find a commuting-cover to the provided Pauli
string; otherwise, find an exact cover.

Returns:
A list of source indices forming the exact or commuting cover, or
``None`` if no such cover could be found.
"""
basis: dict[int, tuple[int, int]] = {}
for i, source in enumerate(sources):
result = _solve_linear_system(
basis, source.to_int(on_qubits, commute_with), 1 << i
)
if result is not None and commute_with is not None:
return _int_to_bit_indices(result)
if commute_with is not None:
return None
result = _solve_linear_system(
basis, target.to_int(on_qubits, commute_with), update_basis=False
)
return None if result is None else _int_to_bit_indices(result)


def find_exact_cover(
target: PauliString, sources: list[PauliString]
) -> list[int] | None:
Expand All @@ -64,6 +25,9 @@ def find_exact_cover(
resulting_pauli_string = resulting_pauli_string * sources[i]
assert resulting_pauli_string == target, "Should hold"

If multiple valid covers exist, only one will be returned. This choice is
deterministic, but not necessarily with the lowest weight.

Args:
target: the stabilizers to cover with stabilizers from ``sources``.
sources: stabilizers that can be used to cover ``target``.
Expand All @@ -82,38 +46,34 @@ def find_exact_cover(
if not sources:
return None

# We want an exact (i.e., equality) cover on all qubits, to be sure that
# the post-condition in the docstring holds. For that, it is sufficient to
# only consider all the qubits where either `target` or at least one of the
# items of `sources` acts non-trivially (i.e., something else than the identity).
involved_qubits = frozenset(target.qubits)
# Each Pauli string is encoded as a GF(2) bit-vector concatenating its X and
# Z masks (see ``PauliString.exact_cover_vector``). ``shift`` keeps the two
# halves disjoint and must exceed the highest involved qubit index, so it is
# derived from the union of all the supports.
support = target.support
for source in sources:
involved_qubits |= frozenset(source.qubits)
support |= source.support
shift = support.bit_length()

return _find_cover(target, sources, involved_qubits)
basis: dict[int, tuple[int, int]] = {}
for i, source in enumerate(sources):
_solve_linear_system(basis, source.exact_cover_vector(shift), 1 << i)
result = _solve_linear_system(
basis, target.exact_cover_vector(shift), update_basis=False
)
return None if result is None else _int_to_bit_indices(result)


def find_commuting_cover_on_target_qubits(
target: PauliString, sources: list[PauliString]
) -> list[int] | None:
"""Try to find a set of boundary stabilizers from ``sources`` that generate a
superset of ``target``.

This function tries to find a set of Pauli strings from ``sources`` that
includes ``target`` (i.e., on every qubit where ``target`` is non-trivial,
the product of each of the returned Pauli strings should commute with
``target``).

The differences with :func:`find_cover` are:
"""Try to find a set of boundary stabilizers from ``sources`` whose product
commutes with ``target`` on every qubit where ``target`` is non-trivial.

1. this function does not restrict the output of the product of each of
the returned Pauli string on qubits where ``target`` acts trivially (i.e.
"I"). So in practice, on qubits where ``target[qubit] == "I"``, the value
of the returned Pauli string can be anything.
2. this function does not restrict the output of the product of each of
the returned Pauli string to exactly match ``target`` on qubits where
it acts non-trivially, but rather requires the output to commute with
``target`` on those qubits.
Unlike :func:`find_exact_cover`, this function does not constrain the product
of the returned Pauli strings on qubits where ``target`` acts trivially, and
only requires the product to commute with ``target`` (rather than to equal
it) on qubits where ``target`` is non-trivial.

Args:
target: the stabilizers to cover with stabilizers from ``sources``.
Expand All @@ -125,7 +85,19 @@ def find_commuting_cover_on_target_qubits(
"""
if not sources:
return None
return _find_cover(target, sources, frozenset(target.qubits), commute_with=target)

# Each source is encoded as the bit-vector of its per-qubit anti-commutation
# with ``target`` (see ``PauliString.commuting_cover_vector``). A subset of
# sources commutes with ``target`` exactly when their vectors XOR to zero,
# i.e. when a source is linearly dependent on the previous ones.
basis: dict[int, tuple[int, int]] = {}
for i, source in enumerate(sources):
result = _solve_linear_system(
basis, source.commuting_cover_vector(target), 1 << i
)
if result is not None:
return _int_to_bit_indices(result)
return None


def _solve_linear_system(
Expand Down
Loading