Skip to content

Commit 576835d

Browse files
authored
[open-systems] Superoperator tensor contraction (#1596)
This pull request is the third in a series for supporting open system (classical data & measurement), roadmap item #445. ~~This branch is based on #1590 (for my convenience; it doesn't use any of that functionality)~~ ## `Bloq.tensor_contract(superoperator=True)` This is the new interface. It will return a 0-, 2-, or 4-dimensional tensor for constants, density matrices, and superoperators (resp) to support simulating programs with measurements and non-unitary maps. Read the docs for the indexing convention for the superoperator; but if you're really doing something with the superoperators you may want to operate on the quimb tensor network with friendly indices itself. ## `cbloq_to_superquimb` The workhorse of the new functionality is this function that converts a composite bloq to a tensor network that represents the density matrix and/or superoperator of the program. Some operations like measurement or discarding qubits cannot be represented in a pure-state statevector / unitary picture. `cbloq_to_superquimb` still uses `Bloq.my_tensors` to construct the network. It adds each tensor twice: once in the "forward" direction and again in the "backward" (adjoint) direction. ## `DiscardInd` The `Bloq.my_tensors` method can now return a `DiscardInd` object, which signifies that that tensor index should be discarded by tracing it out. It turns out that this simple extension lets you represent any CPTP map using the "system+environment" modeling approach. ## `MeasZ`, `Discard` This PR includes two non-unitary maps: measuring in the Z basis and discarding a qubit or classical bit. ## Manipulating quimb tensor networks This PR makes some changes to the way we handle outer indices in the quimb tensor network. This should only be of interest to folks who use the "hands on" approach of directly manipulating the tensor networks. The changes make sense and clean up some of the docs. The outer indices are now re-mapped to tuples of qualtran objects that actually make sense rather than keeping them as `Connection`s to `DanglingT` objects. There is a new flag `friendly_indices=True` which will make the outer indices friendly strings. **breaking change** I renamed and made-private the `get_left_and_right_inds` function. The original idea was to use this as a public way of getting a handle on the outer indices, but it was always tricky to use; and needed to be changed to support the new variety of outer indices present in the superoperator networks. No one should need a replacement for this function since the `qtn.TensorNetwork` object outer indices now just intrinsically make sense. ## Non-changes This includes the minimal set of non-unitary CPTP bloqs to support the docs notebooks.
1 parent ee158fb commit 576835d

15 files changed

Lines changed: 1160 additions & 145 deletions

File tree

qualtran/_infra/bloq.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,18 @@
6161
SympySymbolAllocator,
6262
)
6363
from qualtran.simulation.classical_sim import ClassicalValRetT, ClassicalValT
64+
from qualtran.simulation.tensor import DiscardInd
6465

6566

6667
def _decompose_from_build_composite_bloq(bloq: 'Bloq') -> 'CompositeBloq':
6768
from qualtran import BloqBuilder
6869

6970
bb, initial_soqs = BloqBuilder.from_signature(bloq.signature, add_registers_allowed=False)
7071
out_soqs = bloq.build_composite_bloq(bb=bb, **initial_soqs)
72+
if not isinstance(out_soqs, dict):
73+
raise ValueError(
74+
f'{bloq}.build_composite_bloq must return a dictionary mapping right register names to output soquets.'
75+
)
7176
return bb.finalize(**out_soqs)
7277

7378

@@ -258,21 +263,45 @@ def call_classically(
258263
res = self.as_composite_bloq().on_classical_vals(**vals)
259264
return tuple(res[reg.name] for reg in self.signature.rights())
260265

261-
def tensor_contract(self) -> 'NDArray':
262-
"""Return a contracted, dense ndarray representing this bloq.
266+
def tensor_contract(self, superoperator: bool = False) -> 'NDArray':
267+
"""Return a contracted, dense ndarray encoding of this bloq.
268+
269+
This method decomposes and flattens this bloq into a factorized CompositeBloq,
270+
turns that composite bloq into a Quimb tensor network, and contracts it into a dense
271+
ndarray.
272+
273+
The returned array will be 0-, 1-, 2-, or 4-dimensional with indices arranged according to the
274+
bloq's signature and the type of simulation requested via the `superoperator` flag.
275+
276+
If `superoperator` is set to False (the default), a pure-state tensor network will be
277+
constructed.
278+
- If `bloq` has all thru-registers, the dense tensor will be 2-dimensional with shape `(n, n)`
279+
where `n` is the number of bits in the signature. We follow the linear algebra convention
280+
and order the indices as (right, left) so the matrix-vector product can be used to evolve
281+
a state vector.
282+
- If `bloq` has all left- or all right-registers, the tensor will be 1-dimensional with
283+
shape `(n,)`. Note that we do not distinguish between 'row' and 'column' vectors in this
284+
function.
285+
- If `bloq` has no external registers, the contracted form is a 0-dimensional complex number.
286+
287+
If `superoperator` is set to True, an open-system tensor network will be constructed.
288+
- States result in a 2-dimensional density matrix with indices (right_forward, right_backward)
289+
or (left_forward, left_backward) depending on whether they're input or output states.
290+
- Operations result in a 4-dimensional tensor with indices (right_forward, right_backward,
291+
left_forward, left_backward).
263292
264-
This constructs a tensor network and then contracts it according to our registers,
265-
i.e. the dangling indices. The returned array will be 0-, 1- or 2-dimensional. If it is
266-
a 2-dimensional matrix, we follow the quantum computing / matrix multiplication convention
267-
of (right, left) indices.
293+
Args:
294+
superoperator: If toggled to True, do an open-system simulation. This supports
295+
non-unitary operations like measurement, but is more costly and results in
296+
higher-dimension resultant tensors.
268297
"""
269298
from qualtran.simulation.tensor import bloq_to_dense
270299

271-
return bloq_to_dense(self)
300+
return bloq_to_dense(self, superoperator=superoperator)
272301

273302
def my_tensors(
274303
self, incoming: Dict[str, 'ConnectionT'], outgoing: Dict[str, 'ConnectionT']
275-
) -> List['qtn.Tensor']:
304+
) -> List[Union['qtn.Tensor', 'DiscardInd']]:
276305
"""Override this method to support native quimb simulation of this Bloq.
277306
278307
This method is responsible for returning tensors corresponding to the unitary, state, or

qualtran/_infra/composite_bloq.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ def flatten_once(
371371
in_soqs = _map_soqs(in_soqs, soq_map) # update `in_soqs` from old to new.
372372
if pred(binst):
373373
try:
374-
new_out_soqs = bb.add_from(binst.bloq.decompose_bloq(), **in_soqs)
374+
new_out_soqs = bb.add_from(binst.bloq, **in_soqs)
375375
did_work = True
376376
except (DecomposeTypeError, DecomposeNotImplementedError):
377377
new_out_soqs = tuple(soq for _, soq in bb._add_binst(binst, in_soqs=in_soqs))

qualtran/_infra/controlled_test.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from qualtran.bloqs.for_testing import TestAtom, TestParallelCombo, TestSerialCombo
3434
from qualtran.drawing import get_musical_score_data
3535
from qualtran.drawing.musical_score import Circle, SoqData, TextBox
36-
from qualtran.simulation.tensor import cbloq_to_quimb, get_right_and_left_inds
36+
from qualtran.simulation.tensor import cbloq_to_quimb, quimb_to_dense
3737
from qualtran.symbolics import Shaped
3838

3939
if TYPE_CHECKING:
@@ -432,10 +432,8 @@ def test_controlled_tensor_without_decompose():
432432
cgate = cirq.ControlledGate(cirq.CSWAP, control_values=ctrl_spec.to_cirq_cv())
433433

434434
tn = cbloq_to_quimb(ctrl_bloq.as_composite_bloq())
435-
# pylint: disable=unbalanced-tuple-unpacking
436-
right, left = get_right_and_left_inds(tn, ctrl_bloq.signature)
437-
# pylint: enable=unbalanced-tuple-unpacking
438-
np.testing.assert_allclose(tn.to_dense(right, left), cirq.unitary(cgate), atol=1e-8)
435+
tn_dense = quimb_to_dense(tn, ctrl_bloq.signature)
436+
np.testing.assert_allclose(tn_dense, cirq.unitary(cgate), atol=1e-8)
439437
np.testing.assert_allclose(ctrl_bloq.tensor_contract(), cirq.unitary(cgate), atol=1e-8)
440438

441439

qualtran/bloqs/basic_gates/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"""
2323

2424
from .cnot import CNOT
25+
from .discard import Discard, DiscardQ
2526
from .global_phase import GlobalPhase
2627
from .hadamard import CHadamard, Hadamard
2728
from .identity import Identity
@@ -35,4 +36,14 @@
3536
from .toffoli import Toffoli
3637
from .x_basis import MinusEffect, MinusState, PlusEffect, PlusState, XGate
3738
from .y_gate import CYGate, YGate
38-
from .z_basis import CZ, IntEffect, IntState, OneEffect, OneState, ZeroEffect, ZeroState, ZGate
39+
from .z_basis import (
40+
CZ,
41+
IntEffect,
42+
IntState,
43+
MeasZ,
44+
OneEffect,
45+
OneState,
46+
ZeroEffect,
47+
ZeroState,
48+
ZGate,
49+
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
from functools import cached_property
15+
from typing import Dict, List, TYPE_CHECKING
16+
17+
from attrs import frozen
18+
19+
from qualtran import Bloq, CBit, ConnectionT, QBit, Register, Side, Signature
20+
from qualtran.simulation.classical_sim import ClassicalValT
21+
22+
if TYPE_CHECKING:
23+
from qualtran.simulation.tensor import DiscardInd
24+
25+
26+
@frozen
27+
class Discard(Bloq):
28+
"""Discard a classical bit.
29+
30+
This is an allowed operation.
31+
"""
32+
33+
@cached_property
34+
def signature(self) -> 'Signature':
35+
return Signature([Register('c', CBit(), side=Side.LEFT)])
36+
37+
def on_classical_vals(self, c: int) -> Dict[str, 'ClassicalValT']:
38+
return {}
39+
40+
def my_tensors(
41+
self, incoming: Dict[str, 'ConnectionT'], outgoing: Dict[str, 'ConnectionT']
42+
) -> List['DiscardInd']:
43+
44+
from qualtran.simulation.tensor import DiscardInd
45+
46+
return [DiscardInd((incoming['c'], 0))]
47+
48+
49+
@frozen
50+
class DiscardQ(Bloq):
51+
"""Discard a qubit.
52+
53+
This is a dangerous operation that can ruin your computation. This is equivalent to
54+
measuring the qubit and throwing out the measurement operation, so it removes any coherences
55+
involved with the qubit. Use with care.
56+
"""
57+
58+
@cached_property
59+
def signature(self) -> 'Signature':
60+
return Signature([Register('q', QBit(), side=Side.LEFT)])
61+
62+
def my_tensors(
63+
self, incoming: Dict[str, 'ConnectionT'], outgoing: Dict[str, 'ConnectionT']
64+
) -> List['DiscardInd']:
65+
66+
from qualtran.simulation.tensor import DiscardInd
67+
68+
return [DiscardInd((incoming['q'], 0))]
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
import numpy as np
15+
import pytest
16+
17+
from qualtran import BloqBuilder
18+
from qualtran.bloqs.basic_gates import (
19+
CNOT,
20+
Discard,
21+
DiscardQ,
22+
MeasZ,
23+
PlusState,
24+
ZeroEffect,
25+
ZeroState,
26+
)
27+
28+
29+
def test_discard():
30+
bb = BloqBuilder()
31+
q = bb.add(ZeroState())
32+
c = bb.add(MeasZ(), q=q)
33+
bb.add(Discard(), c=c)
34+
cbloq = bb.finalize()
35+
36+
# We're allowed to discard classical bits in the classical simulator
37+
ret = cbloq.call_classically()
38+
assert ret == ()
39+
40+
k = cbloq.tensor_contract(superoperator=True)
41+
np.testing.assert_allclose(k, 1.0, atol=1e-8)
42+
43+
44+
def test_discard_vs_project():
45+
# Using the ZeroState effect un-physically projects us, giving trace of 0.5
46+
bb = BloqBuilder()
47+
q = bb.add(PlusState())
48+
bb.add(ZeroEffect(), q=q)
49+
cbloq = bb.finalize()
50+
k = cbloq.tensor_contract(superoperator=True)
51+
np.testing.assert_allclose(k, 0.5, atol=1e-8)
52+
53+
# Measure and discard is trace preserving
54+
bb = BloqBuilder()
55+
q = bb.add(PlusState())
56+
c = bb.add(MeasZ(), q=q)
57+
bb.add(Discard(), c=c)
58+
cbloq = bb.finalize()
59+
k = cbloq.tensor_contract(superoperator=True)
60+
np.testing.assert_allclose(k, 1.0, atol=1e-8)
61+
62+
63+
def test_discardq():
64+
# Completely dephasing map
65+
# https://learning.quantum.ibm.com/course/general-formulation-of-quantum-information/quantum-channels#the-completely-dephasing-channel
66+
bb = BloqBuilder()
67+
q = bb.add_register('q', 1)
68+
env = bb.add(ZeroState())
69+
q, env = bb.add(CNOT(), ctrl=q, target=env)
70+
bb.add(DiscardQ(), q=env)
71+
cbloq = bb.finalize(q=q)
72+
ss = cbloq.tensor_contract(superoperator=True)
73+
74+
should_be = np.zeros((2, 2, 2, 2))
75+
should_be[0, 0, 0, 0] = 1
76+
should_be[1, 1, 1, 1] = 1
77+
78+
np.testing.assert_allclose(ss, should_be, atol=1e-8)
79+
80+
# Classical simulator will not let you throw out qubits
81+
with pytest.raises(NotImplementedError, match=r'.*classical simulation.*'):
82+
_ = cbloq.call_classically(q=1)

qualtran/bloqs/basic_gates/z_basis.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,18 @@
1313
# limitations under the License.
1414

1515
from functools import cached_property
16-
from typing import cast, Dict, Iterable, List, Optional, Sequence, Tuple, TYPE_CHECKING, Union
16+
from typing import (
17+
cast,
18+
Dict,
19+
Iterable,
20+
List,
21+
Mapping,
22+
Optional,
23+
Sequence,
24+
Tuple,
25+
TYPE_CHECKING,
26+
Union,
27+
)
1728

1829
import attrs
1930
import numpy as np
@@ -27,6 +38,7 @@
2738
bloq_example,
2839
BloqBuilder,
2940
BloqDocSpec,
41+
CBit,
3042
CompositeBloq,
3143
ConnectionT,
3244
CtrlSpec,
@@ -52,7 +64,7 @@
5264

5365
from qualtran.cirq_interop import CirqQuregT
5466
from qualtran.resource_counting import BloqCountDictT, SympySymbolAllocator
55-
from qualtran.simulation.classical_sim import ClassicalValT
67+
from qualtran.simulation.classical_sim import ClassicalValRetT, ClassicalValT
5668

5769
_ZERO = np.array([1, 0], dtype=np.complex128)
5870
_ONE = np.array([0, 1], dtype=np.complex128)
@@ -379,6 +391,44 @@ def _cz() -> CZ:
379391
_CZ_DOC = BloqDocSpec(bloq_cls=CZ, examples=[_cz], call_graph_example=None)
380392

381393

394+
@frozen
395+
class MeasZ(Bloq):
396+
"""Measure a qubit in the Z basis.
397+
398+
Registers:
399+
q [LEFT]: The qubit to measure.
400+
c [RIGHT]: The classical measurement result.
401+
"""
402+
403+
@cached_property
404+
def signature(self) -> 'Signature':
405+
return Signature(
406+
[Register('q', QBit(), side=Side.LEFT), Register('c', CBit(), side=Side.RIGHT)]
407+
)
408+
409+
def on_classical_vals(self, q: int) -> Mapping[str, 'ClassicalValRetT']:
410+
return {'c': q}
411+
412+
def my_tensors(
413+
self, incoming: Dict[str, 'ConnectionT'], outgoing: Dict[str, 'ConnectionT']
414+
) -> List['qtn.Tensor']:
415+
import quimb.tensor as qtn
416+
417+
from qualtran.simulation.tensor import DiscardInd
418+
419+
copy = np.zeros((2, 2, 2), dtype=np.complex128)
420+
copy[0, 0, 0] = 1
421+
copy[1, 1, 1] = 1
422+
# Tie together q, c, and meas_result with the copy tensor; throw out one of the legs.
423+
meas_result = qtn.rand_uuid('meas_result')
424+
t = qtn.Tensor(
425+
data=copy,
426+
inds=[(incoming['q'], 0), (outgoing['c'], 0), (meas_result, 0)],
427+
tags=[str(self)],
428+
)
429+
return [t, DiscardInd((meas_result, 0))]
430+
431+
382432
@frozen
383433
class _IntVector(Bloq):
384434
"""Represent a classical non-negative integer vector (state or effect).

0 commit comments

Comments
 (0)