Skip to content

Commit 0aa39ed

Browse files
perlinmclaude
andauthored
Fix logical operator construction for non-CSS subsystem codes (#578)
* Fix get_logical_ops for non-CSS subsystem codes via symplectic Gram-Schmidt QuditCode.get_logical_ops seeded the GL sector of the standard form with M = inv(U.T @ W), which is non-square whenever the two GL-sector kernels differ in rank: for ~34% of non-CSS subsystem codes this crashed (LinAlgError/IndexError), and for k >= 2 it never enforced intra-type symplectic orthogonality. get_gauge_ops and get_distance inherit the bug through dual().get_logical_ops(). Compute the logical operators instead as a symplectic basis of the gauge group's centralizer C(G) = symplectic_conjugate(canonicalized.matrix).null_space(): the symplectic radical of C(G) is the stabilizer group, so a hyperbolic basis of C(G) is exactly the k dual pairs of logical operators. This is correct by construction over every field and does roughly a third as many Gaussian-elimination passes as the old path. Add math.symplectic_gram_schmidt to split a space under the symplectic form into hyperbolic pairs plus a radical. The non-subsystem standard-form path is unchanged and keeps its pure-Z Z-type logicals; only the subsystem branch is rewritten, so get_standard_form_data's subsystem branch is now covered by a direct test rather than transitively. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Simplify subsystem logical-op tests and move their helpers below the caller Drop two redundant cases from test_qudit_subsystem_logical_ops: the direct-sum conjugations duplicated the k=2 property case (both are k>=2 crash regressions), and the per-field get_distance assertions duplicated the shipped reproduction's. Move the _local_fourier and _direct_sum helpers below the test that uses them. Coverage stays at 100%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address review: restore CSS seeding explanation, tidy sgs, fix docstrings Restore a self-contained derivation of the U/W/M GL-sector seeding in CSSCode.get_logical_ops, whose explanatory comment pointed at a block this change removed from QuditCode.get_logical_ops; also correct a transpose slip carried over from that block (the seeding enforces A @ U = 0 and B @ V = 0). Hoist the symplectic conjugates of the extracted pair out of the per-word projection in symplectic_gram_schmidt. Reword two test docstrings and the _local_fourier docstring to read standalone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Tidy subsystem logical-op code and correct is_subsystem_code docstrings Drop the redundant field view on the centralizer-derived logical operators, and remove the gauge-sector fill-in in QuditCode.get_logical_ops: subsystem codes return early, so that line only ran for non-subsystem codes where the gauge column/row sectors are empty. Underscore the resulting unused sector names. Warn in symplectic_gram_schmidt that promise_full_rank on linearly dependent rows leaves those directions in the radical. Correct both is_subsystem_code docstrings: the method reports whether some parity checks fail to commute, not whether they all commute. Say "fail to commute" rather than "anticommute", since over GF(q) non-commuting Paulis need not pick up a -1 phase. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * revise docstring * comment --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ecbf273 commit 0aa39ed

4 files changed

Lines changed: 262 additions & 44 deletions

File tree

src/qldpc/codes/common.py

Lines changed: 44 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,7 @@ def __str__(self) -> str:
915915
def is_subsystem_code(self) -> bool:
916916
"""Is this code a subsystem code?
917917
918-
That is, do all parity checks commute?
918+
That is, do some parity checks fail to commute with each other?
919919
"""
920920
if self._is_subsystem_code is None:
921921
self._is_subsystem_code = bool(
@@ -1172,17 +1172,17 @@ def get_logical_ops(
11721172
If this method is passed a pauli operator (Pauli.X or Pauli.Z), it returns only the logical
11731173
operators of that type.
11741174
1175-
Due to the way that logical operators are constructed in this method, logical Z-type
1176-
operators only address physical qudits by physical Z-type operators, while logical X-type
1177-
operators address at least one physical qudit with a physical X-type operator, and may
1178-
additionally address physical qudits with physical Z-type operators.
1175+
For a non-subsystem code, logical X-type operators have X-support and may additionally have
1176+
Z-support, while logical Z-type operators have only Z-support and no X-support. These
1177+
logical operators are constructed with a method similar to that in Section 4.1 of
1178+
Gottesman's thesis (arXiv:9705052): fix the values of the logical operator matrix in the GL
1179+
sector of the parity check matrix when written in standard form (see
1180+
QuditCode.get_standard_form_data), and fill in the remaining entries of the logical operator
1181+
matrix as required by commutation constraints.
11791182
1180-
Logical operators are constructed with the method similar to that in Section 4.1 of
1181-
Gottesman's thesis (arXiv:9705052), generalized for subsystem qudit codes. The basic
1182-
strategy is to fix the values of the logical operator matrix in the GL sector of the parity
1183-
check matrix when written in standard form (see QuditCode.get_standard_form_data), and then
1184-
fill in the remaining entries of the logical operator matrix as required by parity check
1185-
constraints.
1183+
For a subsystem code, logical operators are constructed as a symplectic basis of the gauge
1184+
group's centralizer, whose symplectic radical is the stabilizer group. These logical
1185+
operators make no guarantees on their physical support.
11861186
11871187
The symplectic argument is provided for compatibility with CSSCode.get_logical_ops, and must
11881188
always be True for a non-CSS code.
@@ -1198,12 +1198,24 @@ def get_logical_ops(
11981198
if not (self._logical_ops is None or recompute):
11991199
return self._logical_ops
12001200

1201+
# For a subsystem code, extract the logical operators as a symplectic basis of the gauge
1202+
# group's centralizer C(G): the operators commuting with every gauge generator. The
1203+
# symplectic radical of C(G) is the stabilizer group, so a symplectic (hyperbolic) basis of
1204+
# C(G) is exactly the k dual pairs of logical operators.
1205+
if self.is_subsystem_code:
1206+
centralizer = math.symplectic_conjugate(self.canonicalized.matrix).null_space()
1207+
logical_ops, _radical = math.symplectic_gram_schmidt(
1208+
centralizer, promise_full_rank=True
1209+
)
1210+
self._logical_ops = logical_ops
1211+
return self._logical_ops
1212+
12011213
# construct the standard-form parity check matrix
12021214
(
12031215
matrix,
12041216
qudit_locs,
1205-
(rows_sx, rows_gx, rows_sz, rows_gz),
1206-
(cols_sx, cols_gx, cols_lx, cols_sz, _cols_gz, cols_lz),
1217+
(rows_sx, _rows_gx, rows_sz, _rows_gz),
1218+
(cols_sx, _cols_gx, cols_lx, cols_sz, _cols_gz, cols_lz),
12071219
) = self.get_standard_form_data()
12081220
matrix_x = matrix[:, 0, :]
12091221
matrix_z = matrix[:, 1, :]
@@ -1213,34 +1225,8 @@ def get_logical_ops(
12131225
logicals_zz = self.field.Zeros((len(self), self.dimension))
12141226

12151227
# "seed" the logical operators in the GL sector
1216-
if not self.is_subsystem_code:
1217-
logicals_xx[cols_lz] = self.field.Identity(self.dimension)
1218-
logicals_zz[cols_lx] = self.field.Identity(self.dimension)
1219-
1220-
else:
1221-
cols_gl = np.sort(_join_slices(cols_gx, cols_lx)) # indices for all GL columns
1222-
"""
1223-
Focusing on the gauge-qudit rows (i.e., constraints) of the parity check matrix, define
1224-
A = matrix_z[rows_gz, cols_gl],
1225-
B = matrix_x[rows_gx, cols_gl],
1226-
and denote the logical operator components in the GL sector by
1227-
U = logicals_xx[cols_gl],
1228-
V = logicals_zz[cols_gl].
1229-
These components need to satisfy the system of matrix equations
1230-
(1) A @ U.T = 0,
1231-
(2) B @ V.T = 0,
1232-
(3) U.T @ V = I.
1233-
Without loss of generality, we can satisfy (1) and (2) by setting
1234-
U = null_space(A).T
1235-
V = null_space(B).T @ M,
1236-
where the matrix M is determined by subsituting U and V back into (3),
1237-
U.T @ W @ M = I.
1238-
"""
1239-
mat_U = matrix_z[rows_gz, cols_gl].view(self.field).null_space().T
1240-
mat_W = matrix_x[rows_gx, cols_gl].view(self.field).null_space().T
1241-
mat_M = np.linalg.inv(mat_U.T @ mat_W)
1242-
logicals_xx[cols_gl] = mat_U
1243-
logicals_zz[cols_gl] = mat_W @ mat_M
1228+
logicals_xx[cols_lz] = self.field.Identity(self.dimension)
1229+
logicals_zz[cols_lx] = self.field.Identity(self.dimension)
12441230

12451231
# fill in remaining entries by enforcing parity check constraints
12461232
logicals_xx[cols_sz] = -matrix_z[rows_sz] @ logicals_xx
@@ -1249,7 +1235,6 @@ def get_logical_ops(
12491235
# Z support of X-type logicals, as column vectors
12501236
logicals_xz = self.field.Zeros((len(self), self.dimension))
12511237
logicals_xz[cols_lx] = self.field.Identity(self.dimension)
1252-
logicals_xz[cols_gx] = -matrix_x[rows_gx] @ logicals_xz + matrix_z[rows_gx] @ logicals_xx
12531238
logicals_xz[cols_sx] = -matrix_x[rows_sx] @ logicals_xz + matrix_z[rows_sx] @ logicals_xx
12541239

12551240
# full X and Z logicals as row vectors
@@ -2406,7 +2391,7 @@ def from_qecdb_id(code_id: str) -> CSSCode:
24062391
def is_subsystem_code(self) -> bool:
24072392
"""Is this code a subsystem code?
24082393
2409-
That is, do all parity checks commute?
2394+
That is, do some parity checks fail to commute with each other?
24102395
"""
24112396
if self._is_subsystem_code is None:
24122397
self._is_subsystem_code = bool(np.any(self.matrix_x @ self.matrix_z.T))
@@ -2597,7 +2582,22 @@ def get_logical_ops(
25972582
logicals_z[cols_lx] = self.field.Identity(self.dimension)
25982583

25992584
else:
2600-
# see QuditCode.get_logical_ops for an explanation of what's happening here
2585+
# Restrict to the gauge-qudit rows (constraints) and define
2586+
# A = matrix_z[rows_gz, cols_gl] and
2587+
# B = matrix_x[rows_gx, cols_gl].
2588+
# The GL-sector components of the logical operators,
2589+
# U = logicals_x[cols_gl] and
2590+
# V = logicals_z[cols_gl],
2591+
# must satisfy
2592+
# (1) A @ U = 0,
2593+
# (2) B @ V = 0, and
2594+
# (3) U.T @ V = I.
2595+
# Setting
2596+
# U = null_space(A).T and
2597+
# V = null_space(B).T @ M for some M
2598+
# satisfies (1) and (2), and setting
2599+
# M = inv(U.T @ W) with W = null_space(B).T
2600+
# satisfies (3), since U.T @ V = U.T @ W @ M = I.
26012601
cols_gl = np.sort(_join_slices(cols_gx, cols_lx))
26022602
mat_U = matrix_z[rows_gz, cols_gl].view(self.field).null_space().T
26032603
mat_W = matrix_x[rows_gx, cols_gl].view(self.field).null_space().T

src/qldpc/codes/common_test.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,126 @@ def test_qudit_ops(pytestconfig: pytest.Config) -> None:
562562
assert np.array_equal(code.get_stabilizer_ops(canonicalized=True), stabilizer_ops[:-1])
563563

564564

565+
def test_qudit_subsystem_logical_ops() -> None:
566+
"""Non-CSS subsystem codes get a valid symplectic basis of logical operators.
567+
568+
The base QuditCode.get_logical_ops extracts the logical operators of a subsystem code as a
569+
symplectic basis of the gauge group's centralizer. This test checks that basis is valid --
570+
correct commutation relations, and commuting with the gauge group -- for codes whose parity
571+
checks mix X-type and Z-type support.
572+
"""
573+
574+
def assert_valid_basis(code: codes.QuditCode) -> None:
575+
assert code.is_subsystem_code
576+
logical_ops = code.get_logical_ops()
577+
assert len(logical_ops) == 2 * code.dimension
578+
# a valid basis has the full symplectic Gram matrix [[0, I], [-I, 0]]: X-type logicals
579+
# mutually commute, Z-type logicals mutually commute, and each logical anticommutes only
580+
# with its dual
581+
assert np.array_equal(
582+
logical_ops @ math.symplectic_conjugate(logical_ops).T,
583+
get_symplectic_form(code.dimension, code.field),
584+
)
585+
# logical operators commute with every gauge generator (they lie in the centralizer)
586+
assert not np.any(code.matrix @ math.symplectic_conjugate(logical_ops).T)
587+
# gauge operators (dual().get_logical_ops()) are likewise a valid symplectic basis
588+
gauge_ops = code.get_gauge_ops()
589+
assert np.array_equal(
590+
gauge_ops @ math.symplectic_conjugate(gauge_ops).T,
591+
get_symplectic_form(code.gauge_dimension, code.field),
592+
)
593+
# the basis round-trips through the commutation-relation validation of set_logical_ops
594+
code.set_logical_ops(logical_ops)
595+
596+
# conjugating some qudits mixes a Bacon-Shor code's X/Z support into a non-CSS subsystem code.
597+
# The shipped reproduction must yield a valid basis without error, including via get_gauge_ops
598+
# and get_distance, which build the logical operators of the dual code.
599+
shipped = codes.BaconShorCode(3).conjugated([1, 3, 5])
600+
assert_valid_basis(shipped)
601+
assert shipped.get_distance() == 3
602+
603+
# property test over several fields, for one and two logical qudits. Away from GF(2), build
604+
# the non-CSS codes with _local_fourier, as conjugated() is only symplectic over GF(2).
605+
for field in [galois.GF(2), galois.GF(3), galois.GF(4)]:
606+
bacon_shor = codes.BaconShorCode(3, field=field.order).matrix
607+
single = _local_fourier(bacon_shor, [1, 3, 5])
608+
double = _local_fourier(_direct_sum(bacon_shor, bacon_shor), [1, 3, 5, 10, 14])
609+
for matrix, expected_dimension in [(single, 1), (double, 2)]:
610+
code = codes.QuditCode(matrix)
611+
# the parity checks genuinely mix X-type and Z-type support (the code is not CSS)
612+
has_x = np.any(code.matrix[:, : len(code)], axis=1)
613+
has_z = np.any(code.matrix[:, len(code) :], axis=1)
614+
assert np.any(has_x & has_z)
615+
assert code.dimension == expected_dimension
616+
assert_valid_basis(code)
617+
618+
# a subsystem code with no logical qudits yields an empty basis rather than an error
619+
five_qubit = codes.FiveQubitCode()
620+
gauged = codes.QuditCode(np.vstack([five_qubit.matrix, five_qubit.get_logical_ops()]))
621+
assert gauged.is_subsystem_code and gauged.dimension == 0
622+
assert gauged.get_logical_ops().shape == (0, 2 * len(gauged))
623+
624+
625+
def _local_fourier(matrix: galois.FieldArray, qudits: Sequence[int]) -> galois.FieldArray:
626+
"""Apply the local symplectic Fourier map ``(x_i, z_i) -> (z_i, -x_i)`` on the given qudits.
627+
628+
This preserves every symplectic inner product, so it maps a valid code to a valid code over any
629+
field. Applied to some qudits of a CSS code, it can mix X-type and Z-type support to build a
630+
non-CSS code -- the regime that exercises the base QuditCode.get_logical_ops construction (CSS
631+
codes use their own override).
632+
"""
633+
field = type(matrix)
634+
num_qudits = matrix.shape[1] // 2
635+
x_bits, z_bits = matrix[:, :num_qudits].copy(), matrix[:, num_qudits:].copy()
636+
x_bits[:, qudits], z_bits[:, qudits] = z_bits[:, qudits].copy(), -x_bits[:, qudits]
637+
return np.hstack([x_bits, z_bits]).view(field)
638+
639+
640+
def _direct_sum(matrix_a: galois.FieldArray, matrix_b: galois.FieldArray) -> galois.FieldArray:
641+
"""Combine two symplectic parity check matrices into a code acting on disjoint qudits.
642+
643+
The result encodes the logical qudits of both summands, giving a simple way to build subsystem
644+
codes with more than one logical qudit.
645+
"""
646+
field = type(matrix_a)
647+
num_a, num_b = matrix_a.shape[1] // 2, matrix_b.shape[1] // 2
648+
num_qudits = num_a + num_b
649+
result = field.Zeros((len(matrix_a) + len(matrix_b), 2 * num_qudits))
650+
result[: len(matrix_a), :num_a] = matrix_a[:, :num_a]
651+
result[: len(matrix_a), num_qudits : num_qudits + num_a] = matrix_a[:, num_a:]
652+
result[len(matrix_a) :, num_a:num_qudits] = matrix_b[:, :num_b]
653+
result[len(matrix_a) :, num_qudits + num_a :] = matrix_b[:, num_b:]
654+
return result
655+
656+
657+
def test_get_standard_form_data_subsystem() -> None:
658+
"""QuditCode.get_standard_form_data reduces a subsystem code's checks to standard form.
659+
660+
get_logical_ops does not route subsystem codes through get_standard_form_data, so this covers
661+
its subsystem branch directly against the identity-block structure that the method documents.
662+
"""
663+
code = codes.QuditCode(codes.BaconShorCode(3).matrix)
664+
assert code.is_subsystem_code
665+
matrix, qudit_locs, row_sectors, col_sectors = code.get_standard_form_data()
666+
rows_sx, rows_gx, rows_sz, rows_gz = row_sectors
667+
cols_sx, cols_gx, _cols_lx, cols_sz, cols_gz, _cols_lz = col_sectors
668+
669+
# each stabilizer/gauge pivot block is an identity matrix, as documented
670+
for rows, pauli_index, cols in [
671+
(rows_sx, 0, cols_sx),
672+
(rows_gx, 0, cols_gx),
673+
(rows_sz, 1, cols_sz),
674+
(rows_gz, 1, cols_gz),
675+
]:
676+
block = matrix[rows, pauli_index, cols]
677+
assert block.shape[0] == block.shape[1]
678+
assert np.array_equal(block, code.field.Identity(len(block)))
679+
680+
# undoing the qudit permutation recovers the canonicalized parity check matrix
681+
reordered = matrix[:, :, np.argsort(qudit_locs)].reshape(-1, 2 * len(code)).view(code.field)
682+
assert np.array_equal(reordered.row_space(), code.canonicalized.matrix)
683+
684+
565685
def test_set_logical_ops_single_type_support() -> None:
566686
"""QuditCode.set_logical_ops_x/z accept width-n single-type support."""
567687
css_code = codes.SteaneCode()

src/qldpc/math.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,3 +375,64 @@ def _sqrt(value: galois.FieldArray) -> galois.FieldArray:
375375
than a 0-dimensional scalar (which it rejects over some extension fields).
376376
"""
377377
return np.sqrt(np.atleast_1d(value))[0]
378+
379+
380+
def symplectic_gram_schmidt(
381+
vectors: galois.FieldArray, *, promise_full_rank: bool = False
382+
) -> tuple[galois.FieldArray, galois.FieldArray]:
383+
"""Reduce vectors to symplectic hyperbolic pairs and a symplectic radical.
384+
385+
The rows of ``vectors`` span a subspace V of ``GF(q)^(2n)`` equipped with the symplectic inner
386+
product ``⟨a, b⟩_s = a @ symplectic_conjugate(b)`` (see symplectic_conjugate). Return a pair
387+
``(hyperbolic, radical)``:
388+
389+
- ``hyperbolic`` has shape ``(2m, 2n)`` and holds ``m`` mutually orthogonal hyperbolic pairs.
390+
Its rows are ordered ``[b_0, ..., b_{m-1}, c_0, ..., c_{m-1}]``, so that
391+
``hyperbolic @ symplectic_conjugate(hyperbolic).T`` is the block matrix ``[[0, I], [-I, 0]]``:
392+
``⟨b_i, c_j⟩_s = δ_ij`` and all other products vanish.
393+
- ``radical`` spans the symplectic radical of V -- the vectors of V that are orthogonal to all
394+
of V. Its rows are isotropic and orthogonal to every row of ``hyperbolic`` and ``radical``.
395+
396+
Together the rows of ``hyperbolic`` and ``radical`` form a basis for V. The rows of ``vectors``
397+
may be linearly dependent; they are first reduced to a basis of V. Pass promise_full_rank=True
398+
to skip this reduction when the rows are already independent; passing it for dependent rows
399+
leaves the dependent directions in ``radical`` as spurious (possibly zero) rows, though the
400+
``hyperbolic`` pairs stay correct.
401+
402+
Because the symplectic form is alternating, ``⟨v, v⟩_s = 0`` for every vector in every
403+
characteristic, so -- unlike get_orthonormal_basis -- there is no unit-vector case: the
404+
construction peels off one hyperbolic pair at a time and collects the leftover radical.
405+
"""
406+
field = type(vectors)
407+
dimension = vectors.shape[1]
408+
if not promise_full_rank:
409+
vectors = vectors.row_space() # reduce to a basis, discarding linearly dependent rows
410+
words = list(vectors)
411+
412+
firsts: list[galois.FieldArray] = [] # the b_j
413+
partners: list[galois.FieldArray] = [] # the c_j, with ⟨b_j, c_j⟩_s = 1
414+
radical: list[galois.FieldArray] = []
415+
while words:
416+
first = words.pop(0)
417+
index = next(
418+
(ii for ii, word in enumerate(words) if first @ symplectic_conjugate(word) != 0), None
419+
)
420+
if index is None:
421+
# "first" is orthogonal to every remaining word (and, by prior projections, to the
422+
# extracted pairs and radical), so it belongs to the symplectic radical
423+
radical.append(first)
424+
continue
425+
partner = words.pop(index)
426+
# rescale so that ⟨first, partner⟩_s = 1
427+
partner = partner / (first @ symplectic_conjugate(partner))
428+
# project the remaining words to be symplectically orthogonal to both "first" and "partner"
429+
conj_first = symplectic_conjugate(first)
430+
conj_partner = symplectic_conjugate(partner)
431+
words = [
432+
word - (word @ conj_partner) * first + (word @ conj_first) * partner for word in words
433+
]
434+
firsts.append(first)
435+
partners.append(partner)
436+
437+
hyperbolic = field(firsts + partners) if firsts else field.Zeros((0, dimension))
438+
return hyperbolic, field(radical) if radical else field.Zeros((0, dimension))

0 commit comments

Comments
 (0)