From b3502ec3095e6a8a47ef05711e6d3acd940ceb3f Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Sun, 23 Aug 2026 17:32:26 -0500 Subject: [PATCH 1/7] objects: build ring-valued tensor-product operators through RingArray.build ChainComplex.tensor_product fills structurally-zero blocks of each boundary operator with integer zeros. Over a group algebra the result was finalized with a bare array view plus a private _ring assignment, leaving those zeros as plain integers rather than ring members. Whenever a zero block survived -- i.e. for any product whose total complex has three or more links -- an operator mixed ring members with integers: it still satisfied d.d = 0, but lifting it to matrices (lift / regular_lift / to_field_array / transpose) crashed on the integer entries. Rebuild each operator through RingArray.build so every entry is coerced into a ring member. Add a tensor-product test over GF(3)[C3] with three links that validates the complex and lifts every boundary operator. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/qldpc/objects.py | 8 +++----- src/qldpc/objects_test.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/qldpc/objects.py b/src/qldpc/objects.py index 369b1c1cd..63f413e2a 100644 --- a/src/qldpc/objects.py +++ b/src/qldpc/objects.py @@ -574,9 +574,7 @@ def get_zero_block( if chain_a.ring is None: return ChainComplex([op.view(chain_field) for op in matrices], skip_validation=True) - ops = [] - for matrix in matrices: - op = matrix.view(abstract.RingArray) - op._ring = chain_a.ring - ops.append(op) + # structurally-zero blocks are integer-valued, so rebuild each operator through + # RingArray.build to coerce every entry into a ring member of chain_a.ring + ops = [abstract.RingArray.build(matrix, chain_a.ring) for matrix in matrices] return ChainComplex(ops, skip_validation=True) diff --git a/src/qldpc/objects_test.py b/src/qldpc/objects_test.py index f473ac58f..8a595b492 100644 --- a/src/qldpc/objects_test.py +++ b/src/qldpc/objects_test.py @@ -137,6 +137,17 @@ def test_chain_complex(field: int = 3) -> None: assert not np.any(two_chain.op(0)) assert not np.any(two_chain.op(two_chain.num_links + 1)) + # tensor products over a nontrivial commutative group algebra: once the total complex has three + # or more links, some "sector" blocks of a boundary operator are structurally zero, and every + # operator must remain a well-formed RingArray whose entries can be lifted to matrices + cyclic_ring = abstract.GroupRing(abstract.CyclicGroup(3), field) + cyclic_matrix = abstract.RingArray.build(matrix, cyclic_ring) + ring_chain = objects.ChainComplex.tensor_product(cyclic_matrix, cyclic_matrix) + ring_chain = objects.ChainComplex.tensor_product(ring_chain, cyclic_matrix) + ring_chain._validate_ops() + for ring_op in ring_chain.ops: + ring_op.lift() + # invalid chain complex constructions with pytest.raises(ValueError, match="inconsistent operator types"): objects.ChainComplex([matrix, abstract.RingArray.build([[0]])]) From e4a59d59f8c0e01c1f5a2594a622ea1c9ef84af7 Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Sun, 23 Aug 2026 17:34:01 -0500 Subject: [PATCH 2/7] objects: simplify the ring tensor-product comments Drop the jargon about zero blocks; state plainly that each operator is rebuilt so every entry is a ring member. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/qldpc/objects.py | 3 +-- src/qldpc/objects_test.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/qldpc/objects.py b/src/qldpc/objects.py index 63f413e2a..eef1df4b6 100644 --- a/src/qldpc/objects.py +++ b/src/qldpc/objects.py @@ -574,7 +574,6 @@ def get_zero_block( if chain_a.ring is None: return ChainComplex([op.view(chain_field) for op in matrices], skip_validation=True) - # structurally-zero blocks are integer-valued, so rebuild each operator through - # RingArray.build to coerce every entry into a ring member of chain_a.ring + # rebuild each operator through RingArray.build to coerce every entry into a ring member ops = [abstract.RingArray.build(matrix, chain_a.ring) for matrix in matrices] return ChainComplex(ops, skip_validation=True) diff --git a/src/qldpc/objects_test.py b/src/qldpc/objects_test.py index 8a595b492..b17371fb6 100644 --- a/src/qldpc/objects_test.py +++ b/src/qldpc/objects_test.py @@ -137,9 +137,9 @@ def test_chain_complex(field: int = 3) -> None: assert not np.any(two_chain.op(0)) assert not np.any(two_chain.op(two_chain.num_links + 1)) - # tensor products over a nontrivial commutative group algebra: once the total complex has three - # or more links, some "sector" blocks of a boundary operator are structurally zero, and every - # operator must remain a well-formed RingArray whose entries can be lifted to matrices + # a tensor product over a nontrivial commutative group algebra must yield boundary operators + # whose entries are all ring members (and can therefore be lifted to matrices), including the + # three-or-more-link case where some operator blocks are entirely zero cyclic_ring = abstract.GroupRing(abstract.CyclicGroup(3), field) cyclic_matrix = abstract.RingArray.build(matrix, cyclic_ring) ring_chain = objects.ChainComplex.tensor_product(cyclic_matrix, cyclic_matrix) From c8577e454a5ef75f26d8903ba295cd031b475b6f Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Sun, 23 Aug 2026 17:35:33 -0500 Subject: [PATCH 3/7] objects: raise the documented error for malformed qudit-operator strings QuditPauli.from_string promised a ValueError with an "Invalid qudit operator" message for bad input, but an empty factor (e.g. "*", "X(1)*", "") indexed an empty string and raised IndexError, and str.isnumeric() admitted unicode numerics such as the superscript two that int() then rejected with an unrelated message. Index the first character safely and validate digits with str.isdecimal(), so every malformed input raises the documented error. Also correct the swap_xz error message (it named a nonexistent Pauli.dual_xz) and a docstring typo in ChainComplex._validate_ops. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/qldpc/objects.py | 8 ++++---- src/qldpc/objects_test.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/qldpc/objects.py b/src/qldpc/objects.py index eef1df4b6..4255ff5c0 100644 --- a/src/qldpc/objects.py +++ b/src/qldpc/objects.py @@ -57,7 +57,7 @@ def swap_xz(self) -> PauliXZ: if self is Pauli.Z: return Pauli.X raise ValueError( - f"Pauli.dual_xz only converts between Pauli.X and Pauli.Z (provided: {self})" + f"Pauli.swap_xz only converts between Pauli.X and Pauli.Z (provided: {self})" ) def __str__(self) -> str: @@ -144,10 +144,10 @@ def from_string(string: str) -> QuditPauli: raise ValueError(invalid_op) for factor in factors: - pauli = factor[0] + pauli = factor[:1] val_str = factor[2:-1] _factor = f"{pauli}({val_str})" - if pauli not in "XYZ" or not val_str.isnumeric() or factor != _factor: + if pauli not in "XYZ" or not val_str.isdecimal() or factor != _factor: raise ValueError(invalid_op) val = int(val_str) @@ -435,7 +435,7 @@ def __init__( self._validate_ops() def _validate_ops(self) -> None: - """Validate the consistency of this the boundary operators in this chain complex.""" + """Validate the consistency of the boundary operators in this chain complex.""" for op_a, op_b in zip(self.ops, self.ops[1:]): if op_a.shape[1] != op_b.shape[0] or np.any(op_a @ op_b): raise ValueError( diff --git a/src/qldpc/objects_test.py b/src/qldpc/objects_test.py index b17371fb6..cfcbf3418 100644 --- a/src/qldpc/objects_test.py +++ b/src/qldpc/objects_test.py @@ -56,7 +56,7 @@ def test_qudit_operator() -> None: assert -objects.QuditPauli((0, 1)) == objects.QuditPauli((0, -1)) for op in ["I", "Y(1)", "X(1)*Z(2)"]: assert str(objects.QuditPauli.from_string(op)) == op - for op in ["a*b*c", "a(1)"]: + for op in ["a*b*c", "a(1)", "*", "X(1)*", "", "X(²)"]: with pytest.raises(ValueError, match="Invalid qudit operator"): objects.QuditPauli.from_string(op) From 7a3c19a1462ad40f3e61f37d00b7d086a1df6bc8 Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Sun, 23 Aug 2026 17:37:52 -0500 Subject: [PATCH 4/7] objects: remove dead attributes, edge labels, and a redundant method Drop the unused ChainComplex._group annotation, the unused CayleyComplex._graph class attribute, the "L"/"R" edge labels in build_cayley_graph (read nowhere), and Node.__hash__ (the frozen dataclass generates an identical hash from its fields). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/qldpc/objects.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/qldpc/objects.py b/src/qldpc/objects.py index 4255ff5c0..e1690049d 100644 --- a/src/qldpc/objects.py +++ b/src/qldpc/objects.py @@ -172,9 +172,6 @@ class Node: index: int is_data: bool = True - def __hash__(self) -> int: - return hash((self.index, self.is_data)) - def __lt__(self, other: Node) -> bool: if self.is_data == other.is_data: return self.index < other.index @@ -267,9 +264,6 @@ class CayleyComplex: subset_b: set[abstract.GroupMember] bipartite: bool - # geometric data - _graph: nx.Graph | None = None - def __init__( self, subset_a: Collection[abstract.GroupMember], @@ -346,11 +340,11 @@ def build_cayley_graph( # add all edges adjacent to this node for aa in subset_a: aa_gg = aa * gg - graph.add_edge(gg, aa_gg, type="L") # "L" for left-acting + graph.add_edge(gg, aa_gg) new_nodes.add(aa_gg) for bb in subset_b: gg_bb = gg * bb - graph.add_edge(gg, gg_bb, type="R") # "R" for right-acting + graph.add_edge(gg, gg_bb) new_nodes.add(gg_bb) nodes_to_add |= new_nodes - old_nodes @@ -396,8 +390,7 @@ class ChainComplex: _field: type[galois.FieldArray] _ops: tuple[npt.NDArray[np.int_] | abstract.RingArray, ...] - # if boundary operators are defined over a group algebra, keep track of the base group and ring - _group: abstract.Group | None + # if boundary operators are defined over a group algebra, keep track of the base ring _ring: abstract.GroupRing | None def __init__( From f1d5d464823f7e82d7127217b68e21d979f359e5 Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Sun, 23 Aug 2026 23:25:49 -0500 Subject: [PATCH 5/7] objects: keep the L/R acting-side labels on Cayley-graph edges Tag each Cayley-graph edge with type="L"/"R" to record whether it comes from the left- or right-acting generating subset. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/qldpc/objects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/qldpc/objects.py b/src/qldpc/objects.py index e1690049d..f380b30f0 100644 --- a/src/qldpc/objects.py +++ b/src/qldpc/objects.py @@ -340,11 +340,11 @@ def build_cayley_graph( # add all edges adjacent to this node for aa in subset_a: aa_gg = aa * gg - graph.add_edge(gg, aa_gg) + graph.add_edge(gg, aa_gg, type="L") # "L" for left-acting new_nodes.add(aa_gg) for bb in subset_b: gg_bb = gg * bb - graph.add_edge(gg, gg_bb) + graph.add_edge(gg, gg_bb, type="R") # "R" for right-acting new_nodes.add(gg_bb) nodes_to_add |= new_nodes - old_nodes From 2020a70ff16b154908980bdd2e3b704d045d85f7 Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Sun, 23 Aug 2026 23:40:22 -0500 Subject: [PATCH 6/7] objects: assert RingArray in the ring tensor-product test so mypy is happy ChainComplex.ops is typed as the union NDArray | RingArray, so calling .lift() on a loop variable fails mypy (ndarray has no .lift). Assert each operator is a RingArray before lifting -- which also sharpens the test. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/qldpc/objects_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/qldpc/objects_test.py b/src/qldpc/objects_test.py index cfcbf3418..8112a202d 100644 --- a/src/qldpc/objects_test.py +++ b/src/qldpc/objects_test.py @@ -146,6 +146,7 @@ def test_chain_complex(field: int = 3) -> None: ring_chain = objects.ChainComplex.tensor_product(ring_chain, cyclic_matrix) ring_chain._validate_ops() for ring_op in ring_chain.ops: + assert isinstance(ring_op, abstract.RingArray) ring_op.lift() # invalid chain complex constructions From dc080db422b12643674b365b072a9b95d0709868 Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Sun, 23 Aug 2026 23:42:43 -0500 Subject: [PATCH 7/7] objects: comment why the ring tensor-product test asserts RingArray Co-Authored-By: Claude Opus 4.8 (1M context) --- src/qldpc/objects_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/qldpc/objects_test.py b/src/qldpc/objects_test.py index 8112a202d..f3e728bdc 100644 --- a/src/qldpc/objects_test.py +++ b/src/qldpc/objects_test.py @@ -146,6 +146,7 @@ def test_chain_complex(field: int = 3) -> None: ring_chain = objects.ChainComplex.tensor_product(ring_chain, cyclic_matrix) ring_chain._validate_ops() for ring_op in ring_chain.ops: + # every op of a ring-valued chain is a RingArray; assert narrows the union type for .lift() assert isinstance(ring_op, abstract.RingArray) ring_op.lift()