qnmlab 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.
qnmlab/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ qnmlab -- black-hole quasinormal modes with quantum algorithms.
3
+
4
+ import qnmlab as ql
5
+ bh = ql.KazakovSolodukhin(a=0.5)
6
+ ql.reference_modes(bh) # classical ground truth
7
+ op = ql.readout_operator(bh, qubits=3) # modes are its eigenvalues
8
+ r = ql.measure_mode(bh, qubits=3, noise=ql.NoiseModel.emerald_random(), shots=800)
9
+ res = ql.find_modes(ql.Schwarzschild(), qubits=4) # search with no starting guess
10
+ """
11
+ from . import benchmarks, hardware
12
+ from .models import BlackHole, Hayward, KazakovSolodukhin, ReissnerNordstrom, Schwarzschild, available_models
13
+ from .operators import readout_operator, search_pencil
14
+ from .readout import NoiseModel, measure_mode, phase_readout, prepare_state
15
+ from .reference import reference_modes
16
+ from .search import find_modes
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = ["BlackHole", "Schwarzschild", "KazakovSolodukhin", "ReissnerNordstrom", "Hayward",
21
+ "available_models", "reference_modes", "readout_operator", "search_pencil", "NoiseModel",
22
+ "prepare_state", "phase_readout", "measure_mode", "find_modes", "benchmarks", "hardware",
23
+ "__version__"]
File without changes
@@ -0,0 +1,177 @@
1
+ """
2
+ grouping.py -- fewer measurement circuits via GENERAL-commuting (GC) grouping.
3
+
4
+ The hardware run used qubit-wise-commuting (QWC) groups: 28 circuits for the 63
5
+ Pauli strings of H1 and H2 at 3 qubits. QWC cannot do better than 27 here,
6
+ because every one of the 27 weight-3 strings needs its own local basis.
7
+
8
+ General commutation is weaker than qubit-wise commutation, so groups can be much
9
+ larger. For n qubits all 4^n - 1 non-identity Paulis partition into 2^n + 1
10
+ commuting families of 2^n - 1 (mutually unbiased bases): 9 circuits instead of
11
+ 28 at 3 qubits. The price is a short Clifford circuit that rotates each family
12
+ onto Z-strings, which adds two-qubit gates (found by search, minimising CX count).
13
+
14
+ Measured trade-off: see the accompanying paper (noise and mitigation section).
15
+ """
16
+ import heapq
17
+ import itertools
18
+ import math
19
+
20
+ import numpy as np
21
+
22
+ from .hardware_run import _cx_matrix, _embed, _mat1
23
+
24
+ PAULI = {"I": np.eye(2), "X": np.array([[0, 1], [1, 0]]),
25
+ "Y": np.array([[0, -1j], [1j, 0]]), "Z": np.diag([1.0, -1.0])}
26
+
27
+
28
+ def pmat(label):
29
+ M = np.array([[1.0 + 0j]])
30
+ for ch in label:
31
+ M = np.kron(M, PAULI[ch])
32
+ return M
33
+
34
+
35
+ def symp(label):
36
+ """(x bits, z bits) of a Pauli label."""
37
+ x = tuple(int(c in "XY") for c in label)
38
+ z = tuple(int(c in "ZY") for c in label)
39
+ return x, z
40
+
41
+
42
+ def commute(a, b):
43
+ xa, za = symp(a)
44
+ xb, zb = symp(b)
45
+ return (sum(i * j for i, j in zip(xa, zb)) + sum(i * j for i, j in zip(za, xb))) % 2 == 0
46
+
47
+
48
+ def gc_partition(labels):
49
+ """
50
+ Partition Pauli labels into mutually commuting families with as few families
51
+ as possible. Exact-cover search over maximal commuting sets (fine for n <= 3;
52
+ falls back to greedy colouring above that).
53
+ """
54
+ labels = list(labels)
55
+ n = len(labels[0])
56
+ if n <= 3:
57
+ alls = ["".join(t) for t in itertools.product("IXYZ", repeat=n)][1:]
58
+ fams = set()
59
+ for gens in itertools.combinations(alls, n):
60
+ if all(commute(a, b) for a, b in itertools.combinations(gens, 2)):
61
+ grp = {_mul_label(c) for c in _span(gens)} - {"I" * n, None}
62
+ if len(grp) == 2 ** n - 1:
63
+ fams.add(frozenset(grp))
64
+ fams = list(fams)
65
+ target = set(labels)
66
+ best = [None]
67
+
68
+ def search(rem, chosen):
69
+ if best[0] is not None and len(chosen) >= len(best[0]):
70
+ return
71
+ if not rem:
72
+ best[0] = list(chosen)
73
+ return
74
+ p = min(rem)
75
+ for f in fams:
76
+ if p in f and not (f & set().union(*chosen) if chosen else False):
77
+ search(rem - f, chosen + [f])
78
+ search(target, [])
79
+ return [sorted(set(f) & target) for f in best[0]]
80
+ groups = []
81
+ for lab in sorted(labels, key=lambda s: -sum(c != "I" for c in s)):
82
+ for g in groups:
83
+ if all(commute(lab, o) for o in g):
84
+ g.append(lab)
85
+ break
86
+ else:
87
+ groups.append([lab])
88
+ return groups
89
+
90
+
91
+ def _span(gens):
92
+ out = []
93
+ for bits in itertools.product((0, 1), repeat=len(gens)):
94
+ out.append(tuple(g for g, b in zip(gens, bits) if b))
95
+ return out
96
+
97
+
98
+ def _mul_label(factors):
99
+ n = len(factors[0]) if factors else 0
100
+ if not factors:
101
+ return None
102
+ x = [0] * n
103
+ z = [0] * n
104
+ for f in factors:
105
+ fx, fz = symp(f)
106
+ x = [a ^ b for a, b in zip(x, fx)]
107
+ z = [a ^ b for a, b in zip(z, fz)]
108
+ return "".join("I" if (a, b) == (0, 0) else "X" if (a, b) == (1, 0)
109
+ else "Z" if (a, b) == (0, 1) else "Y" for a, b in zip(x, z))
110
+
111
+
112
+ # ---------------------------------------------------------------- diagonalising Cliffords
113
+ def _apply_sym(gate, vecs, n):
114
+ out = []
115
+ for x, z in vecs:
116
+ x, z = list(x), list(z)
117
+ if gate[0] == "h":
118
+ q = gate[1]; x[q], z[q] = z[q], x[q]
119
+ elif gate[0] == "s":
120
+ q = gate[1]; z[q] ^= x[q]
121
+ elif gate[0] == "cx":
122
+ c, t = gate[1], gate[2]; x[t] ^= x[c]; z[c] ^= z[t]
123
+ out.append((tuple(x), tuple(z)))
124
+ return tuple(out)
125
+
126
+
127
+ def diagonalising_circuit(family, n):
128
+ """Cheapest {H, S, CX} circuit (CX cost 1, single-qubit 0.01) mapping every
129
+ member of a commuting family onto a Z-string."""
130
+ gens = [symp(l) for l in family]
131
+ moves = [("h", q) for q in range(n)] + [("s", q) for q in range(n)] + \
132
+ [("cx", c, t) for c in range(n) for t in range(n) if c != t]
133
+ start = tuple(gens)
134
+ pq = [(0.0, 0, start, ())]
135
+ seen = {start: 0.0}
136
+ k = 0
137
+ while pq:
138
+ cost, _, st, path = heapq.heappop(pq)
139
+ if all(not any(x) for x, _ in st):
140
+ return list(path)
141
+ if cost > 6:
142
+ break
143
+ for m in moves:
144
+ nst = _apply_sym(m, st, n)
145
+ nc = cost + (1.0 if m[0] == "cx" else 0.01)
146
+ if nst not in seen or nc < seen[nst] - 1e-12:
147
+ seen[nst] = nc
148
+ k += 1
149
+ heapq.heappush(pq, (nc, k, nst, path + (m,)))
150
+ raise RuntimeError("no diagonalising circuit found")
151
+
152
+
153
+ def to_sim_gates(path):
154
+ """Map {h, s, cx} to the simulator's gate set (S = RZ(pi/2) up to global phase)."""
155
+ return [("h", g[1]) if g[0] == "h" else ("rz", g[1], math.pi / 2) if g[0] == "s"
156
+ else ("cx", g[1], g[2]) for g in path]
157
+
158
+
159
+ def unitary(gates, n):
160
+ U = np.eye(2 ** n, dtype=complex)
161
+ for g in gates:
162
+ if g[0] == "cx":
163
+ G = _cx_matrix(g[1], g[2], n)
164
+ else:
165
+ G = _embed(_mat1(g[0], g[2] if len(g) > 2 else None), g[1], n)
166
+ U = G @ U
167
+ return U
168
+
169
+
170
+ def z_image(label, U, n):
171
+ """U P U^dag = sign * Z-string. Returns (sign, zlabel)."""
172
+ M = U @ pmat(label) @ U.conj().T
173
+ for zl in ("".join(t) for t in itertools.product("IZ", repeat=n)):
174
+ c = np.trace(pmat(zl) @ M) / 2 ** n
175
+ if abs(abs(c) - 1) < 1e-9:
176
+ return (1.0 if np.real(c) > 0 else -1.0), zl
177
+ raise RuntimeError(f"{label} is not diagonalised")
@@ -0,0 +1,405 @@
1
+ """
2
+ hardware.py -- the path from statevector demo to real quantum hardware.
3
+
4
+ This module adds everything the pure-statevector solver (quantum.py) lacks
5
+ for a realistic hardware run:
6
+
7
+ 1. PAULI DECOMPOSITION H = sum_k c_k P_k (P_k = tensor products of
8
+ I,X,Y,Z). Real devices can only measure Pauli
9
+ strings, so this is how any Hermitian observable
10
+ is actually estimated on hardware.
11
+
12
+ 2. NOISY SIMULATION a density-matrix simulator with a depolarizing
13
+ error channel after every gate -- the standard
14
+ first-order model of real-device noise.
15
+
16
+ 3. SHOT SAMPLING expectation values estimated from a finite
17
+ measurement budget, allocated across Pauli terms.
18
+
19
+ 4. SPSA OPTIMIZER the optimizer of choice on noisy hardware:
20
+ it tolerates noisy energies (unlike L-BFGS,
21
+ which chokes on them) and needs only 2 energy
22
+ evaluations per step regardless of the number
23
+ of parameters.
24
+
25
+ 5. ZERO-NOISE EXTRAPOLATION (ZNE) -- the flagship error-MITIGATION method:
26
+ run the same circuit at amplified noise levels
27
+ (1x, 2x, 3x), fit the trend, extrapolate to the
28
+ zero-noise limit. No extra qubits needed.
29
+
30
+ 6. DILATION ENCODING VQE on H(w) = [[0,P],[P^dag,0]] instead of
31
+ R = P^dag P. The ground eigenvalue is
32
+ -sigma_min(P) instead of sigma_min(P)^2, so the
33
+ signal is SQRT-larger -- crucial when noise
34
+ floors are the binding constraint.
35
+
36
+ Terminology check (honesty): these are error *mitigation* techniques. True
37
+ error *correction* (surface codes, logical qubits) needs thousands of physical
38
+ qubits per logical qubit and is not available on today's devices.
39
+ """
40
+
41
+ import numpy as np
42
+ from scipy import linalg
43
+
44
+ from .quantum import ansatz_state # reuse the exact-statevector ansatz
45
+
46
+ # ----------------------------------------------------------------------
47
+ # 1. Pauli decomposition
48
+ # ----------------------------------------------------------------------
49
+ _PAULIS = {
50
+ "I": np.eye(2, dtype=complex),
51
+ "X": np.array([[0, 1], [1, 0]], dtype=complex),
52
+ "Y": np.array([[0, -1j], [1j, 0]], dtype=complex),
53
+ "Z": np.array([[1, 0], [0, -1]], dtype=complex),
54
+ }
55
+
56
+
57
+ def _pauli_matrix(label):
58
+ M = _PAULIS[label[0]]
59
+ for ch in label[1:]:
60
+ M = np.kron(M, _PAULIS[ch])
61
+ return M
62
+
63
+
64
+ def pauli_decompose(H, tol=1e-12):
65
+ """
66
+ Decompose a 2^n x 2^n Hermitian H into Pauli strings:
67
+
68
+ H = sum_k c_k P_k , c_k = Tr(P_k H) / 2^n (real for Hermitian H)
69
+
70
+ Returns a list of (coeff, label) with |coeff| > tol. This is what gets
71
+ measured on a real device: each P_k needs its own measurement basis.
72
+ """
73
+ n = int(np.log2(H.shape[0]))
74
+ assert 2 ** n == H.shape[0]
75
+ labels = ["".join(s) for s in _product("IXYZ", n)]
76
+ terms = []
77
+ for lab in labels:
78
+ c = np.trace(_pauli_matrix(lab) @ H).real / 2 ** n
79
+ if abs(c) > tol:
80
+ terms.append((c, lab))
81
+ return terms
82
+
83
+
84
+ def _product(alphabet, repeat):
85
+ if repeat == 1:
86
+ for ch in alphabet:
87
+ yield (ch,)
88
+ else:
89
+ for ch in alphabet:
90
+ for rest in _product(alphabet, repeat - 1):
91
+ yield (ch,) + rest
92
+
93
+
94
+ def pauli_reconstruct(terms, n):
95
+ """Rebuild the matrix from its Pauli terms (for validation)."""
96
+ H = np.zeros((2 ** n, 2 ** n), dtype=complex)
97
+ for c, lab in terms:
98
+ H += c * _pauli_matrix(lab)
99
+ return H
100
+
101
+
102
+ # ----------------------------------------------------------------------
103
+ # 2. Noisy density-matrix simulator
104
+ # ----------------------------------------------------------------------
105
+ def _RY(t):
106
+ return np.array([[np.cos(t / 2), -np.sin(t / 2)],
107
+ [np.sin(t / 2), np.cos(t / 2)]], dtype=complex)
108
+
109
+
110
+ def _RZ(t):
111
+ return np.array([[np.exp(-1j * t / 2), 0],
112
+ [0, np.exp(1j * t / 2)]], dtype=complex)
113
+
114
+
115
+ def _embed_1q(U, q, n):
116
+ ops = [np.eye(2, dtype=complex)] * n
117
+ ops[q] = U
118
+ M = ops[0]
119
+ for o in ops[1:]:
120
+ M = np.kron(M, o)
121
+ return M
122
+
123
+
124
+ def _cnot_matrix(c, t, n):
125
+ dim = 2 ** n
126
+ M = np.zeros((dim, dim), dtype=complex)
127
+ for i in range(dim):
128
+ bits = [(i >> (n - 1 - k)) & 1 for k in range(n)]
129
+ if bits[c] == 1:
130
+ bits[t] ^= 1
131
+ j = 0
132
+ for b in bits:
133
+ j = (j << 1) | b
134
+ M[j, i] = 1.0
135
+ return M
136
+
137
+
138
+ def _depolarize(rho, qubits, p, n):
139
+ """Depolarizing channel with probability p on the given qubits."""
140
+ if p <= 0:
141
+ return rho
142
+ out = (1 - p) * rho
143
+ for q in qubits:
144
+ for lab in ("X", "Y", "Z"):
145
+ K = _embed_1q(_PAULIS[lab], q, n)
146
+ out += (p / (3 * len(qubits))) * (K @ rho @ K.conj().T)
147
+ return out
148
+
149
+
150
+ class NoisyAnsatzSim:
151
+ """
152
+ Density-matrix simulation of the (RY,RZ)+CNOT ansatz with depolarizing
153
+ noise after every gate.
154
+
155
+ p1 : error probability per single-qubit gate (typical hardware: ~1e-4..1e-3)
156
+ p2 : error probability per CNOT (typical hardware: ~1e-3..1e-2)
157
+
158
+ `noise_scale` multiplies both -- that is the knob ZNE turns.
159
+ """
160
+
161
+ def __init__(self, n_qubits, layers, p1=1e-3, p2=1e-2):
162
+ self.n = n_qubits
163
+ self.L = layers
164
+ self.p1 = p1
165
+ self.p2 = p2
166
+ # pre-build the CNOT matrices once
167
+ self._cnots = [_cnot_matrix(q, q + 1, n_qubits)
168
+ for q in range(n_qubits - 1)]
169
+
170
+ def density_matrix(self, theta, noise_scale=1.0):
171
+ n, L = self.n, self.L
172
+ theta = np.asarray(theta).reshape(L + 1, n, 2)
173
+ p1 = min(self.p1 * noise_scale, 1.0)
174
+ p2 = min(self.p2 * noise_scale, 1.0)
175
+ dim = 2 ** n
176
+ rho = np.zeros((dim, dim), dtype=complex)
177
+ rho[0, 0] = 1.0
178
+ for l in range(L):
179
+ for q in range(n):
180
+ U = _embed_1q(_RY(theta[l, q, 0]), q, n)
181
+ rho = U @ rho @ U.conj().T
182
+ rho = _depolarize(rho, [q], p1, n)
183
+ U = _embed_1q(_RZ(theta[l, q, 1]), q, n)
184
+ rho = U @ rho @ U.conj().T
185
+ rho = _depolarize(rho, [q], p1, n)
186
+ for q in range(n - 1):
187
+ U = self._cnots[q]
188
+ rho = U @ rho @ U.conj().T
189
+ rho = _depolarize(rho, [q, q + 1], p2, n)
190
+ for q in range(n):
191
+ U = _embed_1q(_RY(theta[L, q, 0]), q, n)
192
+ rho = U @ rho @ U.conj().T
193
+ rho = _depolarize(rho, [q], p1, n)
194
+ U = _embed_1q(_RZ(theta[L, q, 1]), q, n)
195
+ rho = U @ rho @ U.conj().T
196
+ rho = _depolarize(rho, [q], p1, n)
197
+ return rho
198
+
199
+ def energy(self, theta, H, noise_scale=1.0, shots=None, rng=None):
200
+ """
201
+ <H> = Tr(rho H), optionally with shot noise.
202
+
203
+ With `shots`, the observable is measured term-by-term in its Pauli
204
+ decomposition with the budget split evenly; each term's estimate gets
205
+ binomial sampling error sqrt((1 - <P>^2)/shots_per_term).
206
+ """
207
+ rho = self.density_matrix(theta, noise_scale)
208
+ if shots is None:
209
+ return float(np.real(np.trace(rho @ H)))
210
+ terms = pauli_decompose(H)
211
+ # identity term costs no measurements
212
+ meas_terms = [(c, l) for c, l in terms if set(l) != {"I"}]
213
+ ident = sum(c for c, l in terms if set(l) == {"I"})
214
+ shots_per = max(1, shots // max(1, len(meas_terms)))
215
+ rng = rng or np.random.default_rng()
216
+ total = ident
217
+ for c, lab in meas_terms:
218
+ P = _pauli_matrix(lab)
219
+ mean = float(np.real(np.trace(rho @ P)))
220
+ mean = np.clip(mean, -1.0, 1.0)
221
+ sampled = mean + rng.normal(0.0, np.sqrt(max(0.0, 1 - mean ** 2) / shots_per))
222
+ total += c * sampled
223
+ return float(total)
224
+
225
+
226
+ # ----------------------------------------------------------------------
227
+ # 3. SPSA -- the noise-tolerant optimizer used on real hardware
228
+ # ----------------------------------------------------------------------
229
+ def spsa_minimize(fun, x0, n_iter=300, a=0.2, c=0.15, seed=0,
230
+ A=None, alpha=0.602, gamma=0.101):
231
+ """
232
+ Simultaneous Perturbation Stochastic Approximation.
233
+
234
+ Each iteration estimates the FULL gradient from just TWO function
235
+ evaluations by perturbing all parameters simultaneously with a random
236
+ +/-1 vector. Gain sequences follow Spall's standard schedule. Because
237
+ it never trusts any single (noisy) evaluation too much, it converges
238
+ where line-search methods (L-BFGS) fail on sampled energies.
239
+ """
240
+ rng = np.random.default_rng(seed)
241
+ x = np.array(x0, dtype=float)
242
+ A = A or n_iter // 10
243
+ best_x, best_f = x.copy(), np.inf
244
+ for k in range(n_iter):
245
+ ak = a / (k + 1 + A) ** alpha
246
+ ck = c / (k + 1) ** gamma
247
+ delta = rng.choice([-1.0, 1.0], size=x.shape)
248
+ f_plus = fun(x + ck * delta)
249
+ f_minus = fun(x - ck * delta)
250
+ ghat = (f_plus - f_minus) / (2 * ck) * delta
251
+ x = x - ak * ghat
252
+ f_now = min(f_plus, f_minus)
253
+ if f_now < best_f:
254
+ best_f, best_x = f_now, x.copy()
255
+ # final unbiased evaluation at the best point
256
+ return best_x, fun(best_x)
257
+
258
+
259
+ # ----------------------------------------------------------------------
260
+ # 4. Zero-noise extrapolation (ZNE)
261
+ # ----------------------------------------------------------------------
262
+ def zne_energy(sim, theta, H, scales=(1.0, 2.0, 3.0), shots=None, rng=None,
263
+ order=2):
264
+ """
265
+ Error-mitigated energy by Richardson extrapolation.
266
+
267
+ Evaluate the SAME circuit at artificially amplified noise levels (on
268
+ hardware this is done by gate folding G -> G G^dag G; in our simulator we
269
+ simply scale the depolarizing probability), then fit a polynomial in the
270
+ noise scale and read off the value at scale 0.
271
+
272
+ Returns (mitigated_energy, raw_energies_at_each_scale).
273
+ """
274
+ es = [sim.energy(theta, H, noise_scale=s, shots=shots, rng=rng)
275
+ for s in scales]
276
+ coeffs = np.polyfit(scales, es, deg=min(order, len(scales) - 1))
277
+ e0 = float(np.polyval(coeffs, 0.0))
278
+ return e0, es
279
+
280
+
281
+ # ----------------------------------------------------------------------
282
+ # 5. VQE runners: noisy, mitigated, and the dilation encoding
283
+ # ----------------------------------------------------------------------
284
+ def vqe_noisy(H, n_qubits, layers=3, p1=1e-3, p2=1e-2, shots=None,
285
+ n_iter=300, restarts=3, seed=0, mitigate=False,
286
+ scales=(1.0, 2.0, 3.0), theta0=None):
287
+ """
288
+ Full noisy VQE: SPSA optimises the (noisy, sampled) energy; the final
289
+ answer is either the raw noisy energy or the ZNE-mitigated one.
290
+
291
+ theta0 : optional warm-start parameters. Standard hardware practice is
292
+ to pre-optimise on a classical simulator and only evaluate (or
293
+ briefly refine) on the device; pass those parameters here. With
294
+ theta0 given and n_iter=0, the function performs pure noisy
295
+ EVALUATION at theta0 (no optimisation) -- the cleanest way to
296
+ isolate "what does noise do to the answer" from "can the
297
+ optimiser survive noise".
298
+
299
+ Returns dict with raw / mitigated energies and the optimal parameters.
300
+ """
301
+ sim = NoisyAnsatzSim(n_qubits, layers, p1=p1, p2=p2)
302
+ nrm = np.linalg.norm(H, 2)
303
+ Hn = H / nrm
304
+ rng = np.random.default_rng(seed)
305
+
306
+ if theta0 is not None and n_iter == 0:
307
+ theta = np.asarray(theta0, dtype=float)
308
+ else:
309
+ best = {"f": np.inf, "x": None}
310
+ starts = ([np.asarray(theta0, dtype=float)] if theta0 is not None else
311
+ [rng.uniform(0, 2 * np.pi, (layers + 1) * n_qubits * 2)
312
+ for _ in range(restarts)])
313
+ for r, x0 in enumerate(starts):
314
+ obj = lambda th: sim.energy(th, Hn, noise_scale=1.0,
315
+ shots=shots, rng=rng)
316
+ x, f = spsa_minimize(obj, x0, n_iter=n_iter, seed=seed + 17 * r)
317
+ if f < best["f"]:
318
+ best = {"f": f, "x": x}
319
+ theta = best["x"]
320
+ raw = sim.energy(theta, Hn, noise_scale=1.0, shots=shots, rng=rng) * nrm
321
+ out = {"raw": raw, "theta": theta, "norm": nrm}
322
+ if mitigate:
323
+ e0, es = zne_energy(sim, theta, Hn, scales=scales, shots=shots, rng=rng)
324
+ out["mitigated"] = e0 * nrm
325
+ out["scale_energies"] = [e * nrm for e in es]
326
+ # noiseless reference with the same parameters (what mitigation aims for)
327
+ st = ansatz_state(theta, n_qubits, layers)
328
+ out["noiseless_same_theta"] = float(np.real(st.conj() @ (H @ st)))
329
+ return out
330
+
331
+
332
+ def vqe_dilation(P, layers=3, beta=0.05, p1=1e-3, p2=1e-2, shots=None,
333
+ n_iter=300, restarts=3, seed=0, mitigate=False,
334
+ scales=(1.0, 2.0, 3.0), theta0=None):
335
+ """
336
+ Estimate sigma_min(P) via VQE on the Hermitian dilation
337
+
338
+ H = [[0, P], [P^dag, 0]] (2N x 2N -> one extra qubit),
339
+
340
+ whose spectrum is {+/- sigma_i(P)}. The pair nearest zero is
341
+ +/- sigma_min.
342
+
343
+ How we target it variationally
344
+ ------------------------------
345
+ Minimise the "folded + pinned" objective
346
+
347
+ F(theta) = <H^2> + beta * <H> , beta small > 0.
348
+
349
+ <H^2> alone is minimised by BOTH +/- sigma_min eigenstates (folded
350
+ spectrum at 0); the small linear pin term breaks the tie in favour of the
351
+ -sigma_min eigenstate. At the optimum, therefore,
352
+
353
+ sigma_min = - <H> (a LINEAR readout).
354
+
355
+ Why this beats the residual encoding under noise
356
+ ------------------------------------------------
357
+ With R = P^dag P the reported number is <R> = sigma_min^2: to resolve a
358
+ sigma_min of 1e-4 you must measure an observable at the 1e-8 level. With
359
+ the dilation the reported number is <H> = -sigma_min itself: you only
360
+ need 1e-4 resolution. The shot cost improves from O(1/sigma^4) to
361
+ O(1/sigma^2) -- exactly the regime where Block C of run_hybrid_demo.py
362
+ showed the residual encoding drowning in noise.
363
+
364
+ Returns dict with: sigma_est (from -<H>), raw/mitigated versions, theta.
365
+ """
366
+ N = P.shape[0]
367
+ Z = np.zeros_like(P)
368
+ H = np.block([[Z, P], [P.conj().T, Z]])
369
+ n_qubits = int(np.log2(H.shape[0]))
370
+ assert 2 ** n_qubits == H.shape[0], "dilation size must be a power of two"
371
+
372
+ nrm = np.linalg.norm(H, 2)
373
+ Hn = H / nrm
374
+ H2n = Hn @ Hn
375
+ sim = NoisyAnsatzSim(n_qubits, layers, p1=p1, p2=p2)
376
+ rng = np.random.default_rng(seed)
377
+
378
+ def objective(th):
379
+ e2 = sim.energy(th, H2n, noise_scale=1.0, shots=shots, rng=rng)
380
+ e1 = sim.energy(th, Hn, noise_scale=1.0, shots=shots, rng=rng)
381
+ return e2 + beta * e1
382
+
383
+ if theta0 is not None and n_iter == 0:
384
+ theta = np.asarray(theta0, dtype=float)
385
+ else:
386
+ best = {"f": np.inf, "x": None}
387
+ starts = ([np.asarray(theta0, dtype=float)] if theta0 is not None else
388
+ [rng.uniform(0, 2 * np.pi, (layers + 1) * n_qubits * 2)
389
+ for _ in range(restarts)])
390
+ for r, x0 in enumerate(starts):
391
+ x, f = spsa_minimize(objective, x0, n_iter=n_iter, seed=seed + 17 * r)
392
+ if f < best["f"]:
393
+ best = {"f": f, "x": x}
394
+ theta = best["x"]
395
+ e1_raw = sim.energy(theta, Hn, noise_scale=1.0, shots=shots, rng=rng)
396
+ out = {"sigma_est": -e1_raw * nrm, "raw_H": e1_raw * nrm,
397
+ "theta": theta, "norm": nrm}
398
+ if mitigate:
399
+ e0, es = zne_energy(sim, theta, Hn, scales=scales, shots=shots, rng=rng)
400
+ out["sigma_mitigated"] = -e0 * nrm
401
+ out["scale_energies"] = [e * nrm for e in es]
402
+ # noiseless reference with the same parameters
403
+ st = ansatz_state(theta, n_qubits, layers)
404
+ out["noiseless_same_theta"] = -float(np.real(st.conj() @ (H @ st)))
405
+ return out