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 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)