Skip to content

Commit cf217a1

Browse files
committed
🛠️ address review findings on the phase-sensitive algebra
Keep redundant generators instead of stripping them --------------------------------------------------- `independent_rows()` in `StabilizerCode.__init__` also stripped redundant rows from `CSSCode`, whose `Hx`/`Hz` are views into the generators. A check matrix with a dependent row came back shorter than it went in, changing the syndrome length; redundancy is deliberate for single-shot decoding and meta-checks. The independence was only needed so `equal_stabilizer_group` could compare `n_rows` against the rank. It now compares group order plus one-sided containment, so redundant generating sets are accepted and kept as given, and the `mod2.row_basis` rewriting in `CSSCode` is no longer necessary. `equal_stabilizer_group` also raised `InvalidPauliError` when both codes had no generators, because `from_paulis` rejects an empty list. Two such codes both generate the trivial group; `is_equivalent` and `get_logical_mapping` were affected through it. Rename `phase` to `phase_exponent` / `phase_exponents` ------------------------------------------------------ The attribute kept its name while changing meaning from a binary sign to a Z4 exponent, so `Pauli(support, 0)` silently went from `+Y` to `-iY` and the last column of `as_vector()`/`as_matrix()` from binary to {0,1,2,3}. Renaming makes downstream code fail loudly instead of computing the wrong thing. Stop `PauliTableau.__getitem__` from aliasing ---------------------------------------------- It returned a `Pauli` whose symplectic support was a view into the tableau while its phase was a copy, so mutating the result wrote back only half way. Both are copies now, which also makes `pauli_row_echelon` structurally unable to modify its argument rather than relying on `__mul__` allocating. Add `PauliTableau.multiply_rows` --------------------------------- Combining rows of a signed tableau is not an XOR, and `optimize_tableau` spelled out the phase bookkeeping four times over fancy-indexed copies. The helper makes the correct operation the easy one and removes the duplication. Performance ----------- The elimination ran O(rows * n) `Pauli.__mul__` calls in a Python loop, and membership tests redid it twice per query. The loop is vectorized and `StabilizerCode` reuses the echelon form it already computes when checking for -I. For an n=761 hypergraph-product code, construction goes 2.40s -> 0.83s and `is_stabilizer` 3.12s -> under 10ms. Also return a `PauliRowEchelon` named tuple rather than a five-tuple, and rename its third field to `n_global_phases` since it is a count, not a rank. Document the conventions ------------------------ `docs/PauliConventions.md` records the i^p X^x Z^z representation, why a positive Hermitian Pauli has p = x.z rather than 0, why XOR-ing signs is not Pauli multiplication, and which layer enforces Hermiticity. Note: `_stabilizer_sign_corrections` in `exact/search.py` still XOR-combines signs across a GF(2) row reduction, which is invalid for non-CSS codes; that is left for a separate issue. Assisted-by: Claude Opus 5 via Claude Code
1 parent ba22c57 commit cf217a1

13 files changed

Lines changed: 459 additions & 180 deletions

File tree

docs/PauliConventions.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Pauli Conventions
2+
3+
This page pins down how Pauli operators, their phases, and stabilizer groups are
4+
represented in `mqt.qecc.codes.core.pauli`. The conventions matter: a phase that
5+
is read as if it were a sign bit produces silently wrong results rather than an
6+
error.
7+
8+
## The representation
9+
10+
A `Pauli` on $n$ qubits is stored as a binary symplectic support $(x \mid z)$
11+
together with a **phase exponent** $p \in \{0,1,2,3\}$:
12+
13+
$$
14+
P = i^{p}\, X^{x} Z^{z}
15+
\qquad\text{where}\qquad
16+
X^{x} Z^{z} = \bigotimes_{j=1}^{n} X^{x_j} Z^{z_j}
17+
$$
18+
19+
The exponent is stored in `Pauli.phase_exponent`; for a `PauliTableau` the
20+
per-row exponents are in `PauliTableau.phase_exponents`.
21+
22+
The single-qubit letters follow from this with $Y = iXZ$:
23+
24+
| letter | $(x_j \mid z_j)$ | contribution to $p$ |
25+
| ------ | ---------------- | ------------------- |
26+
| `I` | $(0 \mid 0)$ | 0 |
27+
| `X` | $(1 \mid 0)$ | 0 |
28+
| `Z` | $(0 \mid 1)$ | 0 |
29+
| `Y` | $(1 \mid 1)$ | 1 |
30+
31+
So a Hermitian Pauli with a `+` sign has $p = x \cdot z$, which is the number of
32+
`Y` letters modulo four — **not** zero. `Pauli(support)` with no explicit
33+
exponent picks exactly this canonical positive Hermitian choice.
34+
35+
## Exponents are not sign bits
36+
37+
`phase_exponent` carries four values, not two. The two are related by
38+
39+
$$
40+
P = (-1)^{r}\, i^{\,x \cdot z}\, X^{x} Z^{z},
41+
\qquad
42+
r = \frac{p - x \cdot z}{2} \bmod 2
43+
$$
44+
45+
Use the explicit converters rather than reaching for the raw exponent:
46+
47+
- `Pauli.sign()` / `PauliTableau.signs()` return the binary sign $r$, and raise
48+
`InvalidPauliError` on a non-Hermitian operator, which has no real sign.
49+
- `Pauli.from_symplectic_and_sign(support, sign)` and
50+
`PauliTableau.phase_from_signs(matrix, signs)` go the other way.
51+
- `Pauli.is_hermitian()` / `PauliTableau.is_hermitian()` test whether a sign
52+
exists at all.
53+
54+
## Multiplication needs a correction term
55+
56+
Because $Z^{z_1} X^{x_2} = (-1)^{z_1 \cdot x_2} X^{x_2} Z^{z_1}$, the product of
57+
two Paulis is
58+
59+
$$
60+
P_1 P_2 = i^{\,p_1 + p_2 + 2 (z_1 \cdot x_2)}
61+
X^{x_1 \oplus x_2} Z^{z_1 \oplus z_2}
62+
$$
63+
64+
The extra $2(z_1 \cdot x_2)$ is why
65+
**XOR-ing symplectic rows and XOR-ing their signs is not Pauli multiplication**.
66+
Concretely:
67+
68+
$$(X \otimes X)(Z \otimes Z) = -\,Y \otimes Y$$
69+
70+
while XOR-ing the two `+` signs would predict $+\,Y \otimes Y$. Note the
71+
correction depends on the number of qubits: $(XXXX)(ZZZZ) = +YYYY$.
72+
73+
Consequences for anyone combining rows of a signed tableau:
74+
75+
- Use `PauliTableau.multiply_rows(target, source)`, never a raw XOR on
76+
`tableau.tableau.data` followed by an XOR on the phases.
77+
- Use `pauli_row_echelon`, not `mod2.row_echelon`, whenever phases must survive
78+
the reduction. A plain mod-2 reduction is only safe on a CSS tableau, where
79+
the pivoting never combines an X-type row with a Z-type row and the correction
80+
term vanishes.
81+
- `PauliTableau.independent_rows()` selects rows by support only and is
82+
explicitly phase-insensitive; do not use it to decide anything about signs.
83+
84+
## Which layer enforces what
85+
86+
The two layers deliberately allow different things:
87+
88+
- **`Pauli` / `PauliTableau` represent the full $n$-qubit Pauli group
89+
$\mathfrak{P}_n$.** Non-Hermitian elements such as `+iX` are legal and
90+
necessary: row reduction genuinely produces them as intermediates, since
91+
$X \cdot Z = -iY$.
92+
- **`StabilizerCode` enforces the stabilizer conditions.** Its constructor
93+
rejects generators that do not commute, are not Hermitian, or together
94+
generate $-I$. Those checks — not the Pauli layer — are what guarantee a valid
95+
code.
96+
97+
Generators need **not** be independent. A redundant generating set is accepted
98+
and kept as given, so `CSSCode` preserves the check matrices you pass in,
99+
including redundant rows that matter for single-shot decoding and meta-checks.
100+
Group-level comparisons (`equal_stabilizer_group`, `stabilizer_equivalent`,
101+
`is_stabilizer`) compare the generated groups and are unaffected by redundancy.
102+
103+
## Subgroups and rank
104+
105+
`pauli_row_echelon` returns a `PauliRowEchelon`, whose `rank` is $\log_2 |G|$
106+
for the generated subgroup $G$. This includes the central scalars, so it can
107+
exceed the number of pivot columns:
108+
109+
| generators | generated subgroup | order | `rank` |
110+
| ------------- | ------------------------------------------------- | ----- | ------ |
111+
| `["XX","ZZ"]` | $\{I, XX, ZZ, -YY\}$ — no scalars beyond $I$ | 4 | 2 |
112+
| `["+iX"]` | $\{I, iX, -I, -iX\}$ — one pivot, four scalars | 4 | 2 |
113+
| `["X","Z"]` | $\{\pm I, \pm X, \pm Z, \pm iY\}$ — anticommuting | 8 | 3 |
114+
115+
The middle row has a single pivot column yet rank 2: the generator squares to
116+
$-I$, so the subgroup contains scalars the support alone cannot account for. The
117+
last row picks up $-I$ from the anticommutator.
118+
119+
To test many Paulis against one subgroup, compute the echelon once and call
120+
`pauli_in_reduced_subgroup`; `PauliTableau.is_in_subgroup` redoes the
121+
elimination on every call.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ self
3030
:maxdepth: 1
3131
3232
installation
33+
PauliConventions
3334
LightsOutDecoder
3435
StatePrep
3536
CatStates

src/mqt/qecc/circuit_synthesis/encoding.py

Lines changed: 20 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@
1818
from mqt.qecc import mod2
1919

2020
from ..codes import CSSCode
21-
from ..codes.core.pauli import CheckMatrix, Pauli, PauliTableau, complete_stabilizer_tableau_with_destabilizers
22-
from ..codes.core.symplectic import SymplecticVector
21+
from ..codes.core.pauli import CheckMatrix, PauliTableau, complete_stabilizer_tableau_with_destabilizers
2322
from .circuits import CliffordIsometry, CNOTCircuit
2423
from .exact import (
2524
Objective,
@@ -370,17 +369,17 @@ def synthesize_encoding_circuit(
370369
additional_z_rows,
371370
))
372371
gens_phase: npt.NDArray[np.int8] = np.hstack((
373-
code.generators.phase,
374-
code.x_logicals.phase[additional_x_checks],
375-
code.z_logicals.phase[additional_z_checks],
372+
code.generators.phase_exponents,
373+
code.x_logicals.phase_exponents[additional_x_checks],
374+
code.z_logicals.phase_exponents[additional_z_checks],
376375
))
377376
log_mat: npt.NDArray[np.int8] = np.vstack((
378377
np.delete(code.x_logicals.tableau.data, additional_checks, axis=0),
379378
np.delete(code.z_logicals.tableau.data, additional_checks, axis=0),
380379
))
381380
log_phase: npt.NDArray[np.int8] = np.hstack((
382-
np.delete(code.x_logicals.phase, additional_checks, axis=0),
383-
np.delete(code.z_logicals.phase, additional_checks, axis=0),
381+
np.delete(code.x_logicals.phase_exponents, additional_checks, axis=0),
382+
np.delete(code.z_logicals.phase_exponents, additional_checks, axis=0),
384383
))
385384

386385
return encoder_from_stabilizers_and_logicals(
@@ -465,61 +464,33 @@ def optimize_tableau(tableau: PauliTableau, stab_rows: list[int]) -> PauliTablea
465464
if i == j:
466465
continue
467466
tab = tableau.copy()
468-
mat = tab.tableau.data
469-
destabs = mat[:half][stab_rows]
470-
stabs = mat[half:][stab_rows]
471467

472468
stab_i_abs, stab_j_abs = half + stab_rows[i], half + stab_rows[j]
473469
destab_i_abs, destab_j_abs = stab_rows[i], stab_rows[j]
474470

475-
new_stab_i = Pauli(SymplecticVector(stabs[i].copy()), tab.phase[stab_i_abs]) * Pauli(
476-
SymplecticVector(stabs[j].copy()), tab.phase[stab_j_abs]
477-
)
478-
new_destab_j = Pauli(SymplecticVector(destabs[j].copy()), tab.phase[destab_j_abs]) * Pauli(
479-
SymplecticVector(destabs[i].copy()), tab.phase[destab_i_abs]
480-
)
481-
482-
stabs[i] ^= stabs[j]
483-
destabs[j] ^= destabs[i]
484-
mat[:half][stab_rows] = destabs
485-
mat[half:][stab_rows] = stabs
486-
tab.phase[stab_i_abs] = new_stab_i.phase
487-
tab.phase[destab_j_abs] = new_destab_j.phase
488-
489-
new_score, _ = score_symplectic(PauliTableau(mat, tab.phase.copy()))
471+
# Multiplying a stabilizer onto another must be mirrored on the
472+
# destabilizers in the opposite direction to preserve the symplectic form.
473+
tab.multiply_rows(stab_i_abs, stab_j_abs)
474+
tab.multiply_rows(destab_j_abs, destab_i_abs)
475+
476+
new_score, _ = score_symplectic(tab)
490477
if lexicographical_compare_np(new_score, best[1]):
491478
best = (tab, new_score)
492479
improved = True
493480
for j in range(len(logical_rows)):
494481
tab = tableau.copy()
495-
mat = tab.tableau.data
496-
destabs = mat[:half][stab_rows]
497-
stabs = mat[half:][stab_rows]
498482

499-
other_log = mat[logical_rows[(j + k) % (2 * k)]]
500483
other_log_abs = logical_rows[(j + k) % (2 * k)]
501484
destab_i_abs = stab_rows[i]
502485
log_j_abs = logical_rows[j]
503486
stab_i_abs = half + stab_rows[i]
504487

505-
new_destab_i = Pauli(SymplecticVector(destabs[i].copy()), tab.phase[destab_i_abs]) * Pauli(
506-
SymplecticVector(other_log.copy()), tab.phase[other_log_abs]
507-
)
508-
509-
destabs[i] ^= other_log
510-
logj = mat[logical_rows[j]]
511-
512-
new_logj = Pauli(SymplecticVector(logj.copy()), tab.phase[log_j_abs]) * Pauli(
513-
SymplecticVector(stabs[i].copy()), tab.phase[stab_i_abs]
514-
)
515-
516-
logj ^= stabs[i]
517-
mat[:half][stab_rows] = destabs
518-
mat[logical_rows[j]] = logj
519-
tab.phase[destab_i_abs] = new_destab_i.phase
520-
tab.phase[log_j_abs] = new_logj.phase
488+
# The stabilizer row is read by the second multiplication, so it must
489+
# not be one of the rows written by the first.
490+
tab.multiply_rows(destab_i_abs, other_log_abs)
491+
tab.multiply_rows(log_j_abs, stab_i_abs)
521492

522-
new_score, _ = score_symplectic(PauliTableau(mat, tab.phase.copy()))
493+
new_score, _ = score_symplectic(tab)
523494
if lexicographical_compare_np(new_score, best[1]):
524495
best = (tab, new_score)
525496
improved = True
@@ -547,11 +518,11 @@ def combine_stabilizer_and_logical_tableau(stabilizers: PauliTableau, logicals:
547518
# Combine stabilizers and logicals into a single tableau
548519
x_logicals = logicals.tableau.data[: logicals.num_rows() // 2]
549520
z_logicals = logicals.tableau.data[logicals.num_rows() // 2 :]
550-
x_logicals_phase = logicals.phase[: logicals.num_rows() // 2]
551-
z_logicals_phase = logicals.phase[logicals.num_rows() // 2 :]
521+
x_logicals_phase = logicals.phase_exponents[: logicals.num_rows() // 2]
522+
z_logicals_phase = logicals.phase_exponents[logicals.num_rows() // 2 :]
552523
combined_matrix = np.vstack([x_logicals, z_logicals, stabilizers.tableau.data])
553524

554-
combined_phase = np.hstack([x_logicals_phase, z_logicals_phase, stabilizers.phase])
525+
combined_phase = np.hstack([x_logicals_phase, z_logicals_phase, stabilizers.phase_exponents])
555526
combined_tableau = PauliTableau(combined_matrix, combined_phase)
556527

557528
# Complete with destabilizers for the stabilizers only

src/mqt/qecc/circuit_synthesis/exact/search.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -506,9 +506,9 @@ def _combine_stabilizers_and_logicals(
506506
])
507507

508508
combined_phase = np.concatenate([
509-
x_logicals.phase,
510-
z_logicals.phase,
511-
stabilizers.phase,
509+
x_logicals.phase_exponents,
510+
z_logicals.phase_exponents,
511+
stabilizers.phase_exponents,
512512
])
513513

514514
return StabilizerTableau(combined_matrix, combined_phase)

src/mqt/qecc/circuit_synthesis/exact/verification.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ def verify_clifford_unitary(circuit: CliffordIsometry, target: StabilizerTableau
4949

5050
act_x: npt.NDArray[np.int8] = actual.tableau.data[:n]
5151
act_z: npt.NDArray[np.int8] = actual.tableau.data[n:]
52-
act_xp: npt.NDArray[np.int8] = actual.phase[:n]
53-
act_zp: npt.NDArray[np.int8] = actual.phase[n:]
52+
act_xp: npt.NDArray[np.int8] = actual.phase_exponents[:n]
53+
act_zp: npt.NDArray[np.int8] = actual.phase_exponents[n:]
5454

5555
tgt_x: npt.NDArray[np.int8] = target.tableau.data[:n]
5656
tgt_z: npt.NDArray[np.int8] = target.tableau.data[n:]
57-
tgt_xp: npt.NDArray[np.int8] = target.phase[:n]
58-
tgt_zp: npt.NDArray[np.int8] = target.phase[n:]
57+
tgt_xp: npt.NDArray[np.int8] = target.phase_exponents[:n]
58+
tgt_zp: npt.NDArray[np.int8] = target.phase_exponents[n:]
5959

6060
# Find permutation π such that actual.X[q] == target.X[π(q)] for each q.
6161
perm: dict[int, int] = {}
@@ -117,9 +117,9 @@ def verify_clifford_isometry(
117117
if num_rows != expected_rows:
118118
return False
119119

120-
x_logicals = StabilizerTableau(target.tableau.data[:k, :], target.phase[:k])
121-
z_logicals = StabilizerTableau(target.tableau.data[k : 2 * k, :], target.phase[k : 2 * k])
122-
stabilizers = StabilizerTableau(target.tableau.data[2 * k :, :], target.phase[2 * k :])
120+
x_logicals = StabilizerTableau(target.tableau.data[:k, :], target.phase_exponents[:k])
121+
z_logicals = StabilizerTableau(target.tableau.data[k : 2 * k, :], target.phase_exponents[k : 2 * k])
122+
stabilizers = StabilizerTableau(target.tableau.data[2 * k :, :], target.phase_exponents[2 * k :])
123123

124124
circuit_code = circuit.get_code()
125125
target_code = StabilizerCode(stabilizers, x_logicals=x_logicals, z_logicals=z_logicals)

src/mqt/qecc/circuit_synthesis/operations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def apply_stabilizer_tableau_inplace(self, tableau: PauliTableau) -> None:
125125
_apply_transvection_numba(
126126
mat[:, self.i], mat[:, self.i + n], mat[:, self.j], mat[:, self.j + n], signs, *self.v
127127
)
128-
tableau.phase = PauliTableau.phase_from_signs(mat, signs)
128+
tableau.phase_exponents = PauliTableau.phase_from_signs(mat, signs)
129129

130130
def apply_stabilizer_tableau(self, tableau: PauliTableau, inplace: bool = False) -> PauliTableau:
131131
"""Apply the transvection operation to a stabilizer tableau."""
@@ -138,7 +138,7 @@ def apply_stabilizer_tableau(self, tableau: PauliTableau, inplace: bool = False)
138138
_apply_transvection_numba(
139139
mat[:, self.i], mat[:, self.i + n], mat[:, self.j], mat[:, self.j + n], signs, *self.v
140140
)
141-
out.phase = PauliTableau.phase_from_signs(mat, signs)
141+
out.phase_exponents = PauliTableau.phase_from_signs(mat, signs)
142142

143143
return out
144144

src/mqt/qecc/circuit_synthesis/transvection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def _score_transvections(
138138
scored: list[tuple[TableauOperation, int | tuple[int, ...]]] = []
139139

140140
original_state = tableau.tableau.data.copy()
141-
original_phase = tableau.phase.copy()
141+
original_phase = tableau.phase_exponents.copy()
142142
for op in operations:
143143
op.apply_stabilizer_tableau_inplace(tableau)
144144
h_vec, _ = score_symplectic(tableau)
@@ -148,7 +148,7 @@ def _score_transvections(
148148
scored.append((op, score_value))
149149

150150
tableau.tableau.data[:] = original_state
151-
tableau.phase[:] = original_phase
151+
tableau.phase_exponents[:] = original_phase
152152
scored.sort(key=operator.itemgetter(1))
153153
return scored
154154

src/mqt/qecc/codes/constructions/concatenation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def _outer_pauli_to_physical(self, p: Pauli) -> Pauli:
8282
msg = "The Pauli operator must have the same number of qubits as the outer code."
8383
raise InvalidStabilizerCodeError(msg)
8484
concatenated = SymplecticVector.zeros(self.n)
85-
phase = p.phase
85+
phase = p.phase_exponent
8686
offset: int = 0
8787
for i in range(self.outer_code.n):
8888
c = self.inner_codes[i]
@@ -100,7 +100,7 @@ def _outer_pauli_to_physical(self, p: Pauli) -> Pauli:
100100
continue
101101
concatenated[offset:new_offset] = block.x_part()
102102
concatenated[offset + self.n : new_offset + self.n] = block.z_part()
103-
phase = (phase + block.phase) % 4
103+
phase = (phase + block.phase_exponent) % 4
104104
offset = new_offset
105105
return Pauli(concatenated, phase)
106106

src/mqt/qecc/codes/core/css_code.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,13 @@ def __init__(
5353
self._check_valid_check_matrices(Hx, Hz)
5454
if Hx is not None:
5555
inferred_n = Hx.shape[1]
56-
hx = np.asarray(mod2.row_basis(Hx), dtype=np.int8)
57-
hz = (
58-
np.asarray(mod2.row_basis(Hz), dtype=np.int8)
59-
if Hz is not None
60-
else np.zeros((0, inferred_n), dtype=np.int8)
61-
)
56+
hx = np.asarray(Hx, dtype=np.int8)
57+
hz = np.asarray(Hz, dtype=np.int8) if Hz is not None else np.zeros((0, inferred_n), dtype=np.int8)
6258
else:
6359
assert Hz is not None
6460
inferred_n = Hz.shape[1]
6561
hx = np.zeros((0, inferred_n), dtype=np.int8)
66-
hz = np.asarray(mod2.row_basis(Hz), dtype=np.int8)
62+
hz = np.asarray(Hz, dtype=np.int8)
6763

6864
num_qubits = hx.shape[1]
6965
if n is not None and n != num_qubits:
@@ -92,7 +88,7 @@ def __init__(
9288
x_logicals = PauliTableau.from_check_matrix(CheckMatrix(lx, "X"))
9389
z_logicals = PauliTableau.from_check_matrix(CheckMatrix(lz, "Z"))
9490

95-
self._num_x_checks = mod2.rank(hx)
91+
self._num_x_checks = hx.shape[0]
9692
super().__init__(
9793
generators,
9894
distance,

0 commit comments

Comments
 (0)