qudenoise 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qudenoise/__init__.py +43 -0
- qudenoise/backend.py +256 -0
- qudenoise/circuit.py +362 -0
- qudenoise/cli.py +241 -0
- qudenoise/gates.py +191 -0
- qudenoise/mps.py +451 -0
- qudenoise/noise.py +312 -0
- qudenoise/observables.py +147 -0
- qudenoise/py.typed +0 -0
- qudenoise/qae.py +464 -0
- qudenoise/reference.py +107 -0
- qudenoise/simulator.py +344 -0
- qudenoise/utils.py +108 -0
- qudenoise-0.1.0.dist-info/LICENSE +21 -0
- qudenoise-0.1.0.dist-info/METADATA +130 -0
- qudenoise-0.1.0.dist-info/RECORD +19 -0
- qudenoise-0.1.0.dist-info/WHEEL +5 -0
- qudenoise-0.1.0.dist-info/entry_points.txt +2 -0
- qudenoise-0.1.0.dist-info/top_level.txt +1 -0
qudenoise/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""QuDenoise: pure-Python MPS quantum circuit simulator with noise and a QAE denoiser."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
__version__ = "0.1.0"
|
|
5
|
+
|
|
6
|
+
from . import backend, gates, noise, observables
|
|
7
|
+
from .backend import (
|
|
8
|
+
get_backend,
|
|
9
|
+
get_rng,
|
|
10
|
+
gpu_available,
|
|
11
|
+
set_backend,
|
|
12
|
+
to_backend,
|
|
13
|
+
to_numpy,
|
|
14
|
+
use_backend,
|
|
15
|
+
)
|
|
16
|
+
from .circuit import Circuit, Op
|
|
17
|
+
from .mps import MPS
|
|
18
|
+
from .noise import KrausChannel
|
|
19
|
+
from .qae import QAE
|
|
20
|
+
from .simulator import ObservableResult, SimulationReport, Simulator
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"__version__",
|
|
24
|
+
"backend",
|
|
25
|
+
"gates",
|
|
26
|
+
"noise",
|
|
27
|
+
"observables",
|
|
28
|
+
"get_backend",
|
|
29
|
+
"get_rng",
|
|
30
|
+
"gpu_available",
|
|
31
|
+
"set_backend",
|
|
32
|
+
"to_backend",
|
|
33
|
+
"to_numpy",
|
|
34
|
+
"use_backend",
|
|
35
|
+
"Circuit",
|
|
36
|
+
"Op",
|
|
37
|
+
"MPS",
|
|
38
|
+
"KrausChannel",
|
|
39
|
+
"QAE",
|
|
40
|
+
"Simulator",
|
|
41
|
+
"SimulationReport",
|
|
42
|
+
"ObservableResult",
|
|
43
|
+
]
|
qudenoise/backend.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Array-backend abstraction layer (NumPy <-> CuPy).
|
|
2
|
+
|
|
3
|
+
This is the *only* library module that imports NumPy. Every other module
|
|
4
|
+
obtains the active array library through :func:`get_backend` so that all
|
|
5
|
+
tensor code is device-agnostic::
|
|
6
|
+
|
|
7
|
+
from qudenoise.backend import get_backend as xp_
|
|
8
|
+
xp = xp_()
|
|
9
|
+
xp.einsum("ab,bc->ac", A, B)
|
|
10
|
+
|
|
11
|
+
CuPy is strictly optional. It is imported lazily, only when GPU support is
|
|
12
|
+
probed (``device="auto"``) or explicitly requested, so a NumPy-only
|
|
13
|
+
installation imports and runs with no errors or warnings about CuPy.
|
|
14
|
+
|
|
15
|
+
The active backend is process-global state (like matplotlib's backend).
|
|
16
|
+
Use :func:`use_backend` for scoped switches.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
from typing import Any, Iterator, Optional
|
|
24
|
+
|
|
25
|
+
import numpy as _np
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("qudenoise")
|
|
28
|
+
|
|
29
|
+
#: dtype used for all state tensors and gates.
|
|
30
|
+
DTYPE = "complex128"
|
|
31
|
+
|
|
32
|
+
_cp: Any = None # the cupy module once successfully loaded
|
|
33
|
+
_cupy_probed = False
|
|
34
|
+
_cupy_error: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
_current: Any = _np
|
|
37
|
+
_current_name = "numpy"
|
|
38
|
+
|
|
39
|
+
_ALIASES = {
|
|
40
|
+
"numpy": "numpy",
|
|
41
|
+
"np": "numpy",
|
|
42
|
+
"cpu": "numpy",
|
|
43
|
+
"cupy": "cupy",
|
|
44
|
+
"cuda": "cupy",
|
|
45
|
+
"gpu": "cupy",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --------------------------------------------------------------------------
|
|
50
|
+
# CuPy detection
|
|
51
|
+
# --------------------------------------------------------------------------
|
|
52
|
+
def _load_cupy() -> Any:
|
|
53
|
+
"""Import CuPy lazily. Returns the module or ``None``; never raises."""
|
|
54
|
+
global _cp, _cupy_probed, _cupy_error
|
|
55
|
+
if _cupy_probed:
|
|
56
|
+
return _cp
|
|
57
|
+
_cupy_probed = True
|
|
58
|
+
try:
|
|
59
|
+
import cupy # type: ignore
|
|
60
|
+
|
|
61
|
+
if cupy.cuda.runtime.getDeviceCount() < 1:
|
|
62
|
+
raise RuntimeError("CuPy is installed but no CUDA device was found")
|
|
63
|
+
_cp = cupy
|
|
64
|
+
except Exception as exc: # ImportError, CUDA runtime errors, ...
|
|
65
|
+
_cp = None
|
|
66
|
+
_cupy_error = f"{type(exc).__name__}: {exc}"
|
|
67
|
+
return _cp
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def gpu_available() -> bool:
|
|
71
|
+
"""True if CuPy is importable *and* a CUDA device is visible."""
|
|
72
|
+
return _load_cupy() is not None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_cupy_array(x: Any) -> bool:
|
|
76
|
+
return type(x).__module__.split(".")[0] == "cupy"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------
|
|
80
|
+
# Backend selection
|
|
81
|
+
# --------------------------------------------------------------------------
|
|
82
|
+
def _normalize(name: str) -> str:
|
|
83
|
+
try:
|
|
84
|
+
return _ALIASES[str(name).lower()]
|
|
85
|
+
except KeyError:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Unknown backend: {name!r} (expected 'numpy' or 'cupy')"
|
|
88
|
+
) from None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def set_backend(name: str) -> Any:
|
|
92
|
+
"""Activate ``'numpy'`` or ``'cupy'`` (aliases: cpu / cuda / gpu).
|
|
93
|
+
|
|
94
|
+
Raises ``RuntimeError`` if CuPy is requested but unavailable.
|
|
95
|
+
Returns the activated array module.
|
|
96
|
+
"""
|
|
97
|
+
global _current, _current_name
|
|
98
|
+
canon = _normalize(name)
|
|
99
|
+
if canon == "cupy":
|
|
100
|
+
cp = _load_cupy()
|
|
101
|
+
if cp is None:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
"CuPy backend requested but CuPy is not installed or no GPU "
|
|
104
|
+
f"was detected ({_cupy_error}). Install a CuPy wheel matching "
|
|
105
|
+
"your CUDA toolkit, e.g. `pip install cupy-cuda12x`."
|
|
106
|
+
)
|
|
107
|
+
_current, _current_name = cp, "cupy"
|
|
108
|
+
else:
|
|
109
|
+
_current, _current_name = _np, "numpy"
|
|
110
|
+
if _debug_enabled():
|
|
111
|
+
validate_svd()
|
|
112
|
+
return _current
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_backend() -> Any:
|
|
116
|
+
"""Return the active array module (``numpy`` or ``cupy``)."""
|
|
117
|
+
return _current
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def backend_name() -> str:
|
|
121
|
+
"""``'numpy'`` or ``'cupy'``."""
|
|
122
|
+
return _current_name
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_gpu() -> bool:
|
|
126
|
+
return _current_name == "cupy"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolve_device(device: str = "auto") -> str:
|
|
130
|
+
"""Map a user-facing device string to a backend name.
|
|
131
|
+
|
|
132
|
+
``"auto"`` -> CuPy when available, else NumPy (never fails).
|
|
133
|
+
``"cpu"``/``"numpy"`` -> NumPy.
|
|
134
|
+
``"cuda"``/``"gpu"``/``"cupy"`` -> CuPy, raising if unavailable.
|
|
135
|
+
"""
|
|
136
|
+
if str(device).lower() == "auto":
|
|
137
|
+
return "cupy" if gpu_available() else "numpy"
|
|
138
|
+
canon = _normalize(device)
|
|
139
|
+
if canon == "cupy" and not gpu_available():
|
|
140
|
+
raise RuntimeError(
|
|
141
|
+
f"device={device!r} requested but CuPy/GPU is unavailable "
|
|
142
|
+
f"({_cupy_error}). Use device='auto' to fall back to CPU."
|
|
143
|
+
)
|
|
144
|
+
return canon
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def configure(device: str = "auto", *, log: bool = True) -> str:
|
|
148
|
+
"""Resolve ``device``, activate it, and (optionally) log the choice.
|
|
149
|
+
|
|
150
|
+
Returns the active backend name.
|
|
151
|
+
"""
|
|
152
|
+
name = resolve_device(device)
|
|
153
|
+
set_backend(name)
|
|
154
|
+
if log:
|
|
155
|
+
if name == "cupy":
|
|
156
|
+
logger.info("QuDenoise: running on GPU (CuPy)")
|
|
157
|
+
elif str(device).lower() == "auto":
|
|
158
|
+
logger.info("QuDenoise: no GPU/CuPy found, running on CPU (NumPy)")
|
|
159
|
+
else:
|
|
160
|
+
logger.info("QuDenoise: running on CPU (NumPy)")
|
|
161
|
+
return name
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@contextlib.contextmanager
|
|
165
|
+
def use_backend(name: str) -> Iterator[Any]:
|
|
166
|
+
"""Temporarily switch backend inside a ``with`` block."""
|
|
167
|
+
prev = _current_name
|
|
168
|
+
xp = set_backend(name)
|
|
169
|
+
try:
|
|
170
|
+
yield xp
|
|
171
|
+
finally:
|
|
172
|
+
set_backend(prev)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --------------------------------------------------------------------------
|
|
176
|
+
# Host <-> device movement
|
|
177
|
+
# --------------------------------------------------------------------------
|
|
178
|
+
def to_backend(array: Any, dtype: Optional[str] = None) -> Any:
|
|
179
|
+
"""Move / convert ``array`` onto the active backend."""
|
|
180
|
+
if _current_name == "cupy":
|
|
181
|
+
out = _current.asarray(array) # numpy->cupy copies, cupy->cupy no-op
|
|
182
|
+
else:
|
|
183
|
+
out = _np.asarray(array.get() if _is_cupy_array(array) else array)
|
|
184
|
+
if dtype is not None:
|
|
185
|
+
out = out.astype(dtype, copy=False)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def to_numpy(array: Any) -> Any:
|
|
190
|
+
"""Return a host ``numpy.ndarray`` regardless of where ``array`` lives."""
|
|
191
|
+
if _is_cupy_array(array):
|
|
192
|
+
return array.get()
|
|
193
|
+
return _np.asarray(array)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --------------------------------------------------------------------------
|
|
197
|
+
# RNG
|
|
198
|
+
# --------------------------------------------------------------------------
|
|
199
|
+
def get_rng(seed: Optional[int] = None) -> Any:
|
|
200
|
+
"""Backend-appropriate ``Generator`` (``default_rng``), explicitly seeded.
|
|
201
|
+
|
|
202
|
+
There is no global RNG state anywhere in the library: generators are
|
|
203
|
+
created here and passed explicitly to whoever needs randomness.
|
|
204
|
+
"""
|
|
205
|
+
return _current.random.default_rng(seed)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def spawn_seeds(seed: Optional[int], n: int) -> list[int]:
|
|
209
|
+
"""Derive ``n`` statistically independent integer seeds from ``seed``.
|
|
210
|
+
|
|
211
|
+
Trajectory ``i`` always receives the same seed for a given master seed,
|
|
212
|
+
independent of how trajectories are scheduled across workers.
|
|
213
|
+
"""
|
|
214
|
+
ss = _np.random.SeedSequence(seed)
|
|
215
|
+
return [int(child.generate_state(1, dtype="uint64")[0]) for child in ss.spawn(n)]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def draw_uniform(rng: Any) -> float:
|
|
219
|
+
"""One uniform ``[0, 1)`` sample as a Python float, on either backend."""
|
|
220
|
+
return float(rng.random())
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --------------------------------------------------------------------------
|
|
224
|
+
# SVD self-check
|
|
225
|
+
# --------------------------------------------------------------------------
|
|
226
|
+
def _debug_enabled() -> bool:
|
|
227
|
+
return os.environ.get("QUDENOISE_DEBUG", "").lower() in ("1", "true", "yes", "on")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def validate_svd(tol: float = 1e-10, seed: int = 1234) -> None:
|
|
231
|
+
"""Check ``linalg.svd`` on the active backend across several shapes.
|
|
232
|
+
|
|
233
|
+
A silently wrong SVD would corrupt every truncation step, so this runs
|
|
234
|
+
automatically on backend switches when ``QUDENOISE_DEBUG=1`` and can be
|
|
235
|
+
called explicitly at any time. Raises ``RuntimeError`` on failure.
|
|
236
|
+
"""
|
|
237
|
+
xp = _current
|
|
238
|
+
rng = _np.random.default_rng(seed)
|
|
239
|
+
for shape in [(2, 2), (4, 4), (8, 4), (4, 8), (16, 16), (32, 8), (1, 4)]:
|
|
240
|
+
host = rng.normal(size=shape) + 1j * rng.normal(size=shape)
|
|
241
|
+
m = to_backend(host, DTYPE)
|
|
242
|
+
u, s, vh = xp.linalg.svd(m, full_matrices=False)
|
|
243
|
+
rec = (u * s) @ vh
|
|
244
|
+
err = float(abs(rec - m).max())
|
|
245
|
+
orth = float(abs(u.conj().T @ u - xp.eye(u.shape[1])).max())
|
|
246
|
+
unsorted = bool((s[:-1] < s[1:] - 1e-12).any())
|
|
247
|
+
if err >= tol or orth >= tol or unsorted:
|
|
248
|
+
raise RuntimeError(
|
|
249
|
+
f"SVD self-check failed on backend {_current_name!r} for shape "
|
|
250
|
+
f"{shape}: reconstruction error {err:.2e}, "
|
|
251
|
+
f"orthogonality error {orth:.2e}, unsorted={unsorted}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if _debug_enabled(): # opt-in import-time check
|
|
256
|
+
validate_svd()
|
qudenoise/circuit.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Circuit builder and compiler.
|
|
2
|
+
|
|
3
|
+
A :class:`Circuit` is a lightweight, backend-independent list of instructions.
|
|
4
|
+
:meth:`Circuit.compile` turns it into a flat, inspectable list of
|
|
5
|
+
``Op(op_type, qubits, data, name)`` records that the simulator consumes:
|
|
6
|
+
|
|
7
|
+
* ``op_type == "gate"`` -> ``data`` is a matrix (``2x2`` or ``4x4``) on the
|
|
8
|
+
active backend, ``qubits`` has length 1 or 2.
|
|
9
|
+
* ``op_type == "noise"`` -> ``data`` is a :class:`~qudenoise.noise.KrausChannel`.
|
|
10
|
+
|
|
11
|
+
Non-adjacent two-qubit operations are made adjacent by wrapping them in SWAP
|
|
12
|
+
chains. Every insertion is logged (``logging`` INFO on the ``qudenoise``
|
|
13
|
+
logger) and recorded in :attr:`Circuit.routing_log`.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
|
|
20
|
+
|
|
21
|
+
from . import gates as G
|
|
22
|
+
from . import noise as N
|
|
23
|
+
from .backend import DTYPE, to_backend, to_numpy
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("qudenoise")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Op(NamedTuple):
|
|
29
|
+
"""One compiled operation."""
|
|
30
|
+
|
|
31
|
+
op_type: str # "gate" | "noise"
|
|
32
|
+
qubits: Tuple[int, ...]
|
|
33
|
+
data: Any # matrix (gate) or KrausChannel (noise)
|
|
34
|
+
name: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Instruction:
|
|
39
|
+
kind: str # "gate" | "noise"
|
|
40
|
+
name: str
|
|
41
|
+
qubits: Tuple[int, ...]
|
|
42
|
+
params: Tuple[float, ...] = ()
|
|
43
|
+
payload: Any = None # host matrix for custom unitaries / KrausChannel for noise
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# inverse rules for named gates
|
|
47
|
+
_SELF_INVERSE = {"i", "x", "y", "z", "h", "cnot", "cz", "swap"}
|
|
48
|
+
_DAGGER_PAIRS = {"s": "sdg", "sdg": "s", "t": "tdg", "tdg": "t"}
|
|
49
|
+
_NEGATE_PARAMS = {"rx", "ry", "rz", "p", "rxx", "ryy", "rzz", "entangler"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Circuit:
|
|
53
|
+
"""Quantum circuit on ``n_qubits`` qubits (qubit 0 = leftmost MPS site)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, n_qubits: int) -> None:
|
|
56
|
+
if n_qubits < 1:
|
|
57
|
+
raise ValueError("n_qubits must be >= 1")
|
|
58
|
+
self.n_qubits = int(n_qubits)
|
|
59
|
+
self._instructions: List[Instruction] = []
|
|
60
|
+
self.routing_log: List[str] = []
|
|
61
|
+
self.n_swaps_inserted: int = 0
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# container protocol
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
def __len__(self) -> int:
|
|
67
|
+
return len(self._instructions)
|
|
68
|
+
|
|
69
|
+
def __iter__(self):
|
|
70
|
+
return iter(self._instructions)
|
|
71
|
+
|
|
72
|
+
def __repr__(self) -> str:
|
|
73
|
+
return f"Circuit(n_qubits={self.n_qubits}, n_instructions={len(self)})"
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def instructions(self) -> List[Instruction]:
|
|
77
|
+
return list(self._instructions)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def has_noise(self) -> bool:
|
|
81
|
+
return any(ins.kind == "noise" for ins in self._instructions)
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# building
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
def _check(self, qubits: Sequence[int]) -> Tuple[int, ...]:
|
|
87
|
+
qs = tuple(int(q) for q in qubits)
|
|
88
|
+
if len(set(qs)) != len(qs):
|
|
89
|
+
raise ValueError(f"Repeated qubit in {qs}")
|
|
90
|
+
for q in qs:
|
|
91
|
+
if not 0 <= q < self.n_qubits:
|
|
92
|
+
raise ValueError(f"Qubit {q} out of range for a {self.n_qubits}-qubit circuit")
|
|
93
|
+
return qs
|
|
94
|
+
|
|
95
|
+
def add_gate(self, name: str, qubits: Sequence[int], *params: float) -> "Circuit":
|
|
96
|
+
"""Append a named gate from :data:`qudenoise.gates.GATES`."""
|
|
97
|
+
key = G.canonical_name(name)
|
|
98
|
+
if key not in G.GATES:
|
|
99
|
+
raise ValueError(f"Unknown gate {name!r}. Known gates: {sorted(G.GATES)}")
|
|
100
|
+
spec = G.GATES[key]
|
|
101
|
+
qs = self._check(qubits)
|
|
102
|
+
if len(qs) != spec.n_qubits:
|
|
103
|
+
raise ValueError(f"Gate {key!r} acts on {spec.n_qubits} qubit(s), got {len(qs)}")
|
|
104
|
+
if len(params) != spec.n_params:
|
|
105
|
+
raise ValueError(f"Gate {key!r} takes {spec.n_params} parameter(s), got {len(params)}")
|
|
106
|
+
self._instructions.append(Instruction("gate", key, qs, tuple(float(p) for p in params)))
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def unitary(self, matrix: Any, qubits: Sequence[int], name: str = "unitary") -> "Circuit":
|
|
110
|
+
"""Append an arbitrary 1- or 2-qubit unitary (columns = input basis, big-endian)."""
|
|
111
|
+
qs = self._check(qubits)
|
|
112
|
+
host = to_numpy(matrix).astype("complex128")
|
|
113
|
+
if len(qs) not in (1, 2) or host.shape != (2 ** len(qs),) * 2:
|
|
114
|
+
raise ValueError("unitary needs a 2x2 (1 qubit) or 4x4 (2 qubit) matrix")
|
|
115
|
+
self._instructions.append(Instruction("gate", name, qs, (), host))
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def add_noise_channel(
|
|
119
|
+
self,
|
|
120
|
+
channel: Union[str, N.KrausChannel],
|
|
121
|
+
qubits: Sequence[int],
|
|
122
|
+
params: Optional[float] = None,
|
|
123
|
+
) -> "Circuit":
|
|
124
|
+
"""Append a noise channel.
|
|
125
|
+
|
|
126
|
+
``channel`` is either a :class:`~qudenoise.noise.KrausChannel` or a
|
|
127
|
+
name (``"depolarizing"``, ``"amplitude_damping"``, ``"phase_damping"``,
|
|
128
|
+
``"bit_flip"``, ``"phase_flip"``) together with its error
|
|
129
|
+
probability/rate in ``params``.
|
|
130
|
+
"""
|
|
131
|
+
qs = self._check(qubits)
|
|
132
|
+
if isinstance(channel, str):
|
|
133
|
+
if params is None:
|
|
134
|
+
raise ValueError(f"Channel {channel!r} needs its error rate in `params`")
|
|
135
|
+
ch = N.make_channel(channel, float(params), n_qubits=len(qs))
|
|
136
|
+
else:
|
|
137
|
+
ch = channel
|
|
138
|
+
if ch.n_qubits != len(qs):
|
|
139
|
+
raise ValueError(f"{ch.name} acts on {ch.n_qubits} qubit(s), got {len(qs)}")
|
|
140
|
+
self._instructions.append(Instruction("noise", ch.name, qs, (), ch))
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
# single-qubit gates
|
|
144
|
+
def i(self, q: int) -> "Circuit":
|
|
145
|
+
return self.add_gate("i", [q])
|
|
146
|
+
|
|
147
|
+
def x(self, q: int) -> "Circuit":
|
|
148
|
+
return self.add_gate("x", [q])
|
|
149
|
+
|
|
150
|
+
def y(self, q: int) -> "Circuit":
|
|
151
|
+
return self.add_gate("y", [q])
|
|
152
|
+
|
|
153
|
+
def z(self, q: int) -> "Circuit":
|
|
154
|
+
return self.add_gate("z", [q])
|
|
155
|
+
|
|
156
|
+
def h(self, q: int) -> "Circuit":
|
|
157
|
+
return self.add_gate("h", [q])
|
|
158
|
+
|
|
159
|
+
def s(self, q: int) -> "Circuit":
|
|
160
|
+
return self.add_gate("s", [q])
|
|
161
|
+
|
|
162
|
+
def sdg(self, q: int) -> "Circuit":
|
|
163
|
+
return self.add_gate("sdg", [q])
|
|
164
|
+
|
|
165
|
+
def t(self, q: int) -> "Circuit":
|
|
166
|
+
return self.add_gate("t", [q])
|
|
167
|
+
|
|
168
|
+
def tdg(self, q: int) -> "Circuit":
|
|
169
|
+
return self.add_gate("tdg", [q])
|
|
170
|
+
|
|
171
|
+
def rx(self, q: int, theta: float) -> "Circuit":
|
|
172
|
+
return self.add_gate("rx", [q], theta)
|
|
173
|
+
|
|
174
|
+
def ry(self, q: int, theta: float) -> "Circuit":
|
|
175
|
+
return self.add_gate("ry", [q], theta)
|
|
176
|
+
|
|
177
|
+
def rz(self, q: int, theta: float) -> "Circuit":
|
|
178
|
+
return self.add_gate("rz", [q], theta)
|
|
179
|
+
|
|
180
|
+
def p(self, q: int, phi: float) -> "Circuit":
|
|
181
|
+
return self.add_gate("p", [q], phi)
|
|
182
|
+
|
|
183
|
+
# two-qubit gates
|
|
184
|
+
def cnot(self, control: int, target: int) -> "Circuit":
|
|
185
|
+
return self.add_gate("cnot", [control, target])
|
|
186
|
+
|
|
187
|
+
cx = cnot
|
|
188
|
+
|
|
189
|
+
def cz(self, a: int, b: int) -> "Circuit":
|
|
190
|
+
return self.add_gate("cz", [a, b])
|
|
191
|
+
|
|
192
|
+
def swap(self, a: int, b: int) -> "Circuit":
|
|
193
|
+
return self.add_gate("swap", [a, b])
|
|
194
|
+
|
|
195
|
+
def rxx(self, a: int, b: int, theta: float) -> "Circuit":
|
|
196
|
+
return self.add_gate("rxx", [a, b], theta)
|
|
197
|
+
|
|
198
|
+
def ryy(self, a: int, b: int, theta: float) -> "Circuit":
|
|
199
|
+
return self.add_gate("ryy", [a, b], theta)
|
|
200
|
+
|
|
201
|
+
def rzz(self, a: int, b: int, theta: float) -> "Circuit":
|
|
202
|
+
return self.add_gate("rzz", [a, b], theta)
|
|
203
|
+
|
|
204
|
+
def entangler(self, a: int, b: int, ax: float, ay: float, az: float) -> "Circuit":
|
|
205
|
+
return self.add_gate("entangler", [a, b], ax, ay, az)
|
|
206
|
+
|
|
207
|
+
# noise shortcuts
|
|
208
|
+
def depolarizing(self, qubits: Union[int, Sequence[int]], p: float) -> "Circuit":
|
|
209
|
+
return self.add_noise_channel("depolarizing", _as_tuple(qubits), p)
|
|
210
|
+
|
|
211
|
+
def amplitude_damping(self, q: int, gamma: float) -> "Circuit":
|
|
212
|
+
return self.add_noise_channel("amplitude_damping", [q], gamma)
|
|
213
|
+
|
|
214
|
+
def phase_damping(self, q: int, lam: float) -> "Circuit":
|
|
215
|
+
return self.add_noise_channel("phase_damping", [q], lam)
|
|
216
|
+
|
|
217
|
+
def bit_flip(self, q: int, p: float) -> "Circuit":
|
|
218
|
+
return self.add_noise_channel("bit_flip", [q], p)
|
|
219
|
+
|
|
220
|
+
def phase_flip(self, q: int, p: float) -> "Circuit":
|
|
221
|
+
return self.add_noise_channel("phase_flip", [q], p)
|
|
222
|
+
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
# composition
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
def extend(self, other: "Circuit") -> "Circuit":
|
|
227
|
+
"""Append all instructions of ``other`` (same qubit count required)."""
|
|
228
|
+
if other.n_qubits != self.n_qubits:
|
|
229
|
+
raise ValueError("Circuits act on different numbers of qubits")
|
|
230
|
+
self._instructions.extend(other._instructions)
|
|
231
|
+
return self
|
|
232
|
+
|
|
233
|
+
def copy(self) -> "Circuit":
|
|
234
|
+
new = Circuit(self.n_qubits)
|
|
235
|
+
new._instructions = list(self._instructions)
|
|
236
|
+
return new
|
|
237
|
+
|
|
238
|
+
def inverse(self) -> "Circuit":
|
|
239
|
+
"""The adjoint circuit (reversed order, inverted gates). Noise is not invertible."""
|
|
240
|
+
inv = Circuit(self.n_qubits)
|
|
241
|
+
for ins in reversed(self._instructions):
|
|
242
|
+
if ins.kind == "noise":
|
|
243
|
+
raise ValueError("Cannot invert a circuit containing noise channels")
|
|
244
|
+
if ins.payload is not None:
|
|
245
|
+
inv._instructions.append(
|
|
246
|
+
Instruction("gate", ins.name + "^dag", ins.qubits, (), ins.payload.conj().T)
|
|
247
|
+
)
|
|
248
|
+
elif ins.name in _SELF_INVERSE:
|
|
249
|
+
inv._instructions.append(ins)
|
|
250
|
+
elif ins.name in _DAGGER_PAIRS:
|
|
251
|
+
inv._instructions.append(Instruction("gate", _DAGGER_PAIRS[ins.name], ins.qubits))
|
|
252
|
+
elif ins.name in _NEGATE_PARAMS:
|
|
253
|
+
inv._instructions.append(
|
|
254
|
+
Instruction("gate", ins.name, ins.qubits, tuple(-p for p in ins.params))
|
|
255
|
+
)
|
|
256
|
+
else: # pragma: no cover - registry/inverse tables out of sync
|
|
257
|
+
raise ValueError(f"No inverse rule for gate {ins.name!r}")
|
|
258
|
+
return inv
|
|
259
|
+
|
|
260
|
+
# ------------------------------------------------------------------
|
|
261
|
+
# compile
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
def compile(self, route: bool = True) -> List[Op]:
|
|
264
|
+
"""Compile to an ordered list of :class:`Op` on the active backend.
|
|
265
|
+
|
|
266
|
+
With ``route=True`` non-adjacent two-qubit operations are wrapped in SWAP
|
|
267
|
+
chains (logged, and recorded in ``self.routing_log``). With
|
|
268
|
+
``route=False`` they are passed through unchanged (used by the dense
|
|
269
|
+
reference simulator, which needs no routing).
|
|
270
|
+
"""
|
|
271
|
+
self.routing_log = []
|
|
272
|
+
self.n_swaps_inserted = 0
|
|
273
|
+
ops: List[Op] = []
|
|
274
|
+
swap = None
|
|
275
|
+
for ins in self._instructions:
|
|
276
|
+
if ins.kind == "gate":
|
|
277
|
+
if ins.payload is not None:
|
|
278
|
+
data = to_backend(ins.payload, DTYPE)
|
|
279
|
+
else:
|
|
280
|
+
data = G.get_gate(ins.name, *ins.params)
|
|
281
|
+
else:
|
|
282
|
+
data = ins.payload
|
|
283
|
+
op = Op(ins.kind, ins.qubits, data, ins.name)
|
|
284
|
+
if len(ins.qubits) == 2 and abs(ins.qubits[0] - ins.qubits[1]) > 1 and route:
|
|
285
|
+
if swap is None:
|
|
286
|
+
swap = G.SWAP()
|
|
287
|
+
ops.extend(self._route(op, swap))
|
|
288
|
+
else:
|
|
289
|
+
ops.append(op)
|
|
290
|
+
return ops
|
|
291
|
+
|
|
292
|
+
def _route(self, op: Op, swap: Any) -> List[Op]:
|
|
293
|
+
a, b = op.qubits
|
|
294
|
+
d = abs(a - b)
|
|
295
|
+
step = 1 if a < b else -1
|
|
296
|
+
# move qubit `a` next to `b`
|
|
297
|
+
path = [(a + step * k, a + step * (k + 1)) for k in range(d - 1)]
|
|
298
|
+
target = (b - step, b)
|
|
299
|
+
n_new = 2 * len(path)
|
|
300
|
+
msg = (
|
|
301
|
+
f"Routing non-adjacent {op.name}{op.qubits}: inserting {n_new} SWAPs "
|
|
302
|
+
f"(qubit {a} -> position {b - step}, then back)"
|
|
303
|
+
)
|
|
304
|
+
logger.info(msg)
|
|
305
|
+
self.routing_log.append(msg)
|
|
306
|
+
self.n_swaps_inserted += n_new
|
|
307
|
+
out = [Op("gate", pair, swap, "swap") for pair in path]
|
|
308
|
+
out.append(Op(op.op_type, target, op.data, op.name))
|
|
309
|
+
out.extend(Op("gate", pair, swap, "swap") for pair in reversed(path))
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
# (de)serialization for the CLI
|
|
314
|
+
# ------------------------------------------------------------------
|
|
315
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
316
|
+
ops: List[Dict[str, Any]] = []
|
|
317
|
+
for ins in self._instructions:
|
|
318
|
+
if ins.kind == "gate":
|
|
319
|
+
if ins.payload is not None:
|
|
320
|
+
raise ValueError("Custom unitaries are not JSON-serializable")
|
|
321
|
+
ops.append({"gate": ins.name, "qubits": list(ins.qubits), "params": list(ins.params)})
|
|
322
|
+
else:
|
|
323
|
+
ch = ins.payload
|
|
324
|
+
if len(ch.params) != 1:
|
|
325
|
+
raise ValueError("Only named single-parameter channels are serializable")
|
|
326
|
+
ops.append(
|
|
327
|
+
{
|
|
328
|
+
"noise": ins.name,
|
|
329
|
+
"qubits": list(ins.qubits),
|
|
330
|
+
"param": next(iter(ch.params.values())),
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
return {"n_qubits": self.n_qubits, "ops": ops}
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def from_dict(cls, spec: Dict[str, Any], n_qubits: Optional[int] = None) -> "Circuit":
|
|
337
|
+
"""Build a circuit from ``{"n_qubits": N, "ops": [...]}``.
|
|
338
|
+
|
|
339
|
+
Each op is ``{"gate": name, "qubits": [...], "params": [...]}`` or
|
|
340
|
+
``{"noise": name, "qubits": [...], "param": rate}`` (``p`` / ``gamma`` /
|
|
341
|
+
``lam`` are accepted as synonyms of ``param``).
|
|
342
|
+
"""
|
|
343
|
+
n = n_qubits if n_qubits is not None else spec.get("n_qubits")
|
|
344
|
+
if n is None:
|
|
345
|
+
raise ValueError("Circuit spec has no 'n_qubits' and none was supplied")
|
|
346
|
+
circ = cls(int(n))
|
|
347
|
+
for i, op in enumerate(spec.get("ops", [])):
|
|
348
|
+
try:
|
|
349
|
+
if "gate" in op:
|
|
350
|
+
circ.add_gate(op["gate"], op["qubits"], *op.get("params", []))
|
|
351
|
+
elif "noise" in op:
|
|
352
|
+
rate = next(op[k] for k in ("param", "p", "gamma", "lam") if k in op)
|
|
353
|
+
circ.add_noise_channel(op["noise"], op["qubits"], rate)
|
|
354
|
+
else:
|
|
355
|
+
raise ValueError("op needs a 'gate' or 'noise' key")
|
|
356
|
+
except (KeyError, StopIteration, ValueError) as exc:
|
|
357
|
+
raise ValueError(f"Bad circuit op #{i} {op!r}: {exc}") from exc
|
|
358
|
+
return circ
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _as_tuple(qubits: Union[int, Sequence[int]]) -> Tuple[int, ...]:
|
|
362
|
+
return (int(qubits),) if isinstance(qubits, int) else tuple(qubits)
|