qmlkit 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.
Files changed (83) hide show
  1. qmlkit/__init__.py +495 -0
  2. qmlkit/_aliases.py +135 -0
  3. qmlkit/algorithms/__init__.py +82 -0
  4. qmlkit/algorithms/adapt.py +297 -0
  5. qmlkit/algorithms/autoencoder.py +206 -0
  6. qmlkit/algorithms/chemistry.py +222 -0
  7. qmlkit/algorithms/clustering.py +149 -0
  8. qmlkit/algorithms/hamiltonians.py +143 -0
  9. qmlkit/algorithms/molecule.py +442 -0
  10. qmlkit/algorithms/qaoa.py +208 -0
  11. qmlkit/algorithms/rl.py +198 -0
  12. qmlkit/algorithms/vqe.py +198 -0
  13. qmlkit/ansatz/__init__.py +68 -0
  14. qmlkit/ansatz/blocks.py +348 -0
  15. qmlkit/ansatz/library.py +570 -0
  16. qmlkit/ansatz/reupload.py +168 -0
  17. qmlkit/baselines.py +604 -0
  18. qmlkit/budget.py +234 -0
  19. qmlkit/core/__init__.py +1 -0
  20. qmlkit/core/backends/__init__.py +22 -0
  21. qmlkit/core/backends/_sampling.py +43 -0
  22. qmlkit/core/backends/base.py +256 -0
  23. qmlkit/core/backends/cirq_backend.py +110 -0
  24. qmlkit/core/backends/cirq_density_backend.py +71 -0
  25. qmlkit/core/backends/noisy.py +86 -0
  26. qmlkit/core/backends/numpy_backend.py +276 -0
  27. qmlkit/core/backends/qiskit_aer_backend.py +79 -0
  28. qmlkit/core/backends/qiskit_backend.py +104 -0
  29. qmlkit/core/backends/registry.py +210 -0
  30. qmlkit/core/backends/spinqit_backend.py +233 -0
  31. qmlkit/core/backends/torch_backend.py +185 -0
  32. qmlkit/core/builder.py +189 -0
  33. qmlkit/core/execute.py +193 -0
  34. qmlkit/core/gates.py +243 -0
  35. qmlkit/core/ir.py +320 -0
  36. qmlkit/core/observables.py +269 -0
  37. qmlkit/datasets.py +178 -0
  38. qmlkit/diagnostics.py +719 -0
  39. qmlkit/draw.py +177 -0
  40. qmlkit/encoding/__init__.py +63 -0
  41. qmlkit/encoding/amplitude.py +178 -0
  42. qmlkit/encoding/angle.py +61 -0
  43. qmlkit/encoding/feature_maps.py +353 -0
  44. qmlkit/encoding/hamiltonian.py +206 -0
  45. qmlkit/encoding/pipeline.py +198 -0
  46. qmlkit/encoding/scaling.py +139 -0
  47. qmlkit/evaluate.py +686 -0
  48. qmlkit/fourier.py +124 -0
  49. qmlkit/generative.py +406 -0
  50. qmlkit/gradients/__init__.py +61 -0
  51. qmlkit/gradients/adjoint.py +138 -0
  52. qmlkit/gradients/batch.py +275 -0
  53. qmlkit/gradients/dispatch.py +247 -0
  54. qmlkit/gradients/hadamard.py +108 -0
  55. qmlkit/gradients/parameter_shift.py +142 -0
  56. qmlkit/gradients/rules.py +151 -0
  57. qmlkit/gradients/spsa.py +134 -0
  58. qmlkit/imbalance.py +335 -0
  59. qmlkit/info.py +153 -0
  60. qmlkit/interop.py +778 -0
  61. qmlkit/kernels/__init__.py +69 -0
  62. qmlkit/kernels/estimators.py +206 -0
  63. qmlkit/kernels/matrix.py +439 -0
  64. qmlkit/kernels/models.py +315 -0
  65. qmlkit/metrics.py +394 -0
  66. qmlkit/nn/__init__.py +18 -0
  67. qmlkit/nn/advanced.py +254 -0
  68. qmlkit/nn/layer.py +343 -0
  69. qmlkit/nn/losses.py +124 -0
  70. qmlkit/nn/models.py +245 -0
  71. qmlkit/optim.py +306 -0
  72. qmlkit/provenance.py +271 -0
  73. qmlkit/py.typed +0 -0
  74. qmlkit/search.py +561 -0
  75. qmlkit/shadows.py +117 -0
  76. qmlkit/utils/__init__.py +19 -0
  77. qmlkit/utils/errors.py +130 -0
  78. qmlkit/utils/shots.py +55 -0
  79. qmlkit-0.1.0.dist-info/METADATA +745 -0
  80. qmlkit-0.1.0.dist-info/RECORD +83 -0
  81. qmlkit-0.1.0.dist-info/WHEEL +4 -0
  82. qmlkit-0.1.0.dist-info/licenses/LICENSE +202 -0
  83. qmlkit-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,222 @@
1
+ r"""Molecular Hamiltonians, computed here rather than quoted.
2
+
3
+ VQE's canonical demonstration is the ground-state energy of H\ :sub:`2`, and most
4
+ tutorials get the Hamiltonian by importing coefficients from a chemistry package or
5
+ copying a table out of a paper. This module computes it: STO-3G integrals over
6
+ Gaussian primitives, symmetry-adapted molecular orbitals, second quantisation, and a
7
+ Jordan–Wigner map to four qubits.
8
+
9
+ That matters for a library whose whole argument is that you should be able to see the
10
+ cost of what you run. It is also checkable — the curve below reproduces the published
11
+ FCI/STO-3G result to five decimals:
12
+
13
+ >>> from qmlkit.algorithms.chemistry import h2_hamiltonian
14
+ >>> from qmlkit.algorithms import exact_ground_energy
15
+ >>> h, info = h2_hamiltonian(0.735)
16
+ >>> round(exact_ground_energy(h, 4), 5)
17
+ -1.13731
18
+
19
+ Minimal basis only, and two centres only. Anything larger wants PySCF or
20
+ OpenFermion, and the point here is transparency rather than coverage.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import itertools
26
+ import math
27
+ from typing import Any
28
+
29
+ import numpy as np
30
+ import numpy.typing as npt
31
+
32
+ from qmlkit.core.observables import PauliString, PauliSum
33
+
34
+ __all__ = [
35
+ "h2_hamiltonian",
36
+ "h2_curve",
37
+ "BOHR_PER_ANGSTROM",
38
+ "HARTREE_TO_KCAL",
39
+ "CHEMICAL_ACCURACY",
40
+ ]
41
+
42
+ #: STO-3G contraction for the hydrogen 1s orbital
43
+ _ALPHA = np.array([3.42525091, 0.62391373, 0.16885540])
44
+ _COEFF = np.array([0.15432897, 0.53532814, 0.44463454])
45
+ _D = _COEFF * (2 * _ALPHA / np.pi) ** 0.75 # fold in the primitive normalisation
46
+
47
+ BOHR_PER_ANGSTROM = 1.0 / 0.529177210903
48
+ HARTREE_TO_KCAL = 627.509474
49
+ #: 1 kcal/mol in hartree — the accuracy chemistry actually cares about
50
+ CHEMICAL_ACCURACY = 1.0 / HARTREE_TO_KCAL
51
+
52
+ _I2 = np.eye(2)
53
+ _Z = np.diag([1.0, -1.0])
54
+ _SIGMA_MINUS = np.array([[0.0, 1.0], [0.0, 0.0]])
55
+ _PAULI: dict[str, npt.NDArray[Any]] = {
56
+ "I": _I2,
57
+ "X": np.array([[0.0, 1.0], [1.0, 0.0]]),
58
+ "Y": np.array([[0.0, -1j], [1j, 0.0]]),
59
+ "Z": _Z,
60
+ }
61
+
62
+
63
+ def _boys(t: float) -> float:
64
+ """Boys function :math:`F_0`, which is all s-type integrals need.
65
+
66
+ `math.erf` rather than SciPy's: qmlkit depends on NumPy alone, and reaching for
67
+ SciPy here would have added a runtime dependency for one scalar special function.
68
+ """
69
+ value = float(t)
70
+ if value < 1e-12:
71
+ return 1.0
72
+ return float(np.sqrt(np.pi / (4 * value)) * math.erf(np.sqrt(value)))
73
+
74
+
75
+ def _kron(*matrices: npt.NDArray[Any]) -> npt.NDArray[Any]:
76
+ out = np.eye(1, dtype=complex)
77
+ for m in matrices:
78
+ out = np.kron(out, m)
79
+ return out
80
+
81
+
82
+ def _ao_integrals(r_bohr: float) -> tuple[npt.NDArray[Any], ...]:
83
+ """Overlap, kinetic, nuclear attraction and two-electron integrals."""
84
+ centres = [np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, r_bohr])]
85
+ n = 2
86
+ overlap = np.zeros((n, n))
87
+ kinetic = np.zeros((n, n))
88
+ nuclear = np.zeros((n, n))
89
+ for i, j in itertools.product(range(n), repeat=2):
90
+ diff = centres[i] - centres[j]
91
+ d2 = float(diff @ diff)
92
+ for a, da in zip(_ALPHA, _D, strict=True):
93
+ for b, db in zip(_ALPHA, _D, strict=True):
94
+ p = a + b
95
+ mu = a * b / p
96
+ gaussian = np.exp(-mu * d2)
97
+ s = (np.pi / p) ** 1.5 * gaussian
98
+ overlap[i, j] += da * db * s
99
+ kinetic[i, j] += da * db * mu * (3 - 2 * mu * d2) * s
100
+ centre = (a * centres[i] + b * centres[j]) / p
101
+ for nucleus in centres: # both hydrogens carry Z = 1
102
+ pc = centre - nucleus
103
+ nuclear[i, j] -= (
104
+ da * db * 2 * np.pi / p * gaussian * float(_boys(p * float(pc @ pc)))
105
+ )
106
+
107
+ eri = np.zeros((n, n, n, n))
108
+ for i, j, k, m in itertools.product(range(n), repeat=4):
109
+ total = 0.0
110
+ dij = centres[i] - centres[j]
111
+ dkm = centres[k] - centres[m]
112
+ for a, da in zip(_ALPHA, _D, strict=True):
113
+ for b, db in zip(_ALPHA, _D, strict=True):
114
+ p = a + b
115
+ centre_p = (a * centres[i] + b * centres[j]) / p
116
+ k_ab = np.exp(-a * b / p * float(dij @ dij))
117
+ for c, dc in zip(_ALPHA, _D, strict=True):
118
+ for d, dd in zip(_ALPHA, _D, strict=True):
119
+ q = c + d
120
+ centre_q = (c * centres[k] + d * centres[m]) / q
121
+ k_cd = np.exp(-c * d / q * float(dkm @ dkm))
122
+ pq = centre_p - centre_q
123
+ total += (
124
+ da
125
+ * db
126
+ * dc
127
+ * dd
128
+ * 2
129
+ * np.pi**2.5
130
+ / (p * q * np.sqrt(p + q))
131
+ * k_ab
132
+ * k_cd
133
+ * float(_boys(p * q / (p + q) * float(pq @ pq)))
134
+ )
135
+ eri[i, j, k, m] = total
136
+ return overlap, kinetic, nuclear, eri
137
+
138
+
139
+ def _annihilator(orbital: int, n_spin_orbitals: int) -> npt.NDArray[Any]:
140
+ """Jordan-Wigner: a Z string for the fermionic sign, then a lowering operator."""
141
+ factors = [_Z] * orbital + [_SIGMA_MINUS] + [_I2] * (n_spin_orbitals - orbital - 1)
142
+ return _kron(*factors)
143
+
144
+
145
+ def _matrix(bond_length_angstrom: float) -> tuple[npt.NDArray[Any], float]:
146
+ """The 16x16 Hamiltonian in the occupation-number basis, plus nuclear repulsion."""
147
+ r = bond_length_angstrom * BOHR_PER_ANGSTROM
148
+ overlap, kinetic, nuclear, eri = _ao_integrals(r)
149
+ core = kinetic + nuclear
150
+
151
+ # For a symmetric two-centre minimal basis the molecular orbitals are fixed by
152
+ # symmetry, so no SCF iteration is needed: sigma_g and sigma_u, normalised.
153
+ s = overlap[0, 1]
154
+ coefficients = np.array(
155
+ [
156
+ [1 / np.sqrt(2 * (1 + s)), 1 / np.sqrt(2 * (1 - s))],
157
+ [1 / np.sqrt(2 * (1 + s)), -1 / np.sqrt(2 * (1 - s))],
158
+ ]
159
+ )
160
+ h = coefficients.T @ core @ coefficients
161
+ g = np.einsum(
162
+ "pi,qj,rk,sl,pqrs->ijkl",
163
+ coefficients,
164
+ coefficients,
165
+ coefficients,
166
+ coefficients,
167
+ eri,
168
+ optimize=True,
169
+ )
170
+
171
+ n = 4 # two spatial orbitals, two spins
172
+ a = [_annihilator(p, n) for p in range(n)]
173
+ adag = [x.conj().T for x in a]
174
+ spin = (0, 1, 0, 1)
175
+ spatial = (0, 0, 1, 1)
176
+
177
+ matrix = np.zeros((2**n, 2**n), dtype=complex)
178
+ for p, q in itertools.product(range(n), repeat=2):
179
+ if spin[p] == spin[q]:
180
+ matrix += h[spatial[p], spatial[q]] * (adag[p] @ a[q])
181
+ for p, q, r_, s_ in itertools.product(range(n), repeat=4):
182
+ if spin[p] == spin[q] and spin[r_] == spin[s_]:
183
+ matrix += (
184
+ 0.5
185
+ * g[spatial[p], spatial[q], spatial[r_], spatial[s_]]
186
+ * (adag[p] @ adag[r_] @ a[s_] @ a[q])
187
+ )
188
+ repulsion = 1.0 / r
189
+ return matrix + repulsion * np.eye(2**n), repulsion
190
+
191
+
192
+ def h2_hamiltonian(
193
+ bond_length: float = 0.735, tol: float = 1e-10
194
+ ) -> tuple[PauliSum, dict[str, Any]]:
195
+ """The H2 qubit Hamiltonian at a given bond length in angstrom.
196
+
197
+ Returns the observable and a dictionary of what went into it. The Pauli
198
+ coefficients come from projecting the dense matrix, ``c_P = Tr(P H) / 2^n``,
199
+ which needs no symbolic algebra and is trivially checkable in the other
200
+ direction with :func:`~qmlkit.algorithms.hamiltonian_matrix`.
201
+ """
202
+ matrix, repulsion = _matrix(bond_length)
203
+ terms: list[PauliString] = []
204
+ for letters in itertools.product("IXYZ", repeat=4):
205
+ operator = _kron(*[_PAULI[c] for c in letters])
206
+ coefficient = float(np.real(np.trace(operator @ matrix)) / 16)
207
+ if abs(coefficient) > tol:
208
+ paulis = tuple((q, c) for q, c in enumerate(letters) if c != "I")
209
+ terms.append(PauliString(paulis, coefficient))
210
+ info = {
211
+ "bond_length": bond_length,
212
+ "n_qubits": 4,
213
+ "n_terms": len(terms),
214
+ "nuclear_repulsion": repulsion,
215
+ "hartree_fock_occupation": [1, 1, 0, 0], # both electrons in sigma_g
216
+ }
217
+ return PauliSum(tuple(terms)), info
218
+
219
+
220
+ def h2_curve(bond_lengths: npt.NDArray[Any] | list[float]) -> list[tuple[float, PauliSum]]:
221
+ """``(bond_length, hamiltonian)`` pairs — the dissociation curve as input data."""
222
+ return [(float(r), h2_hamiltonian(float(r))[0]) for r in bond_lengths]
@@ -0,0 +1,149 @@
1
+ """q-means — Lloyd's algorithm with a quantum distance.
2
+
3
+ The unsupervised gap. k-means is entirely defined by one operation, "how far apart
4
+ are these two points", so replacing that with a quantum kernel distance is the whole
5
+ algorithm:
6
+
7
+ .. math:: d(x, x')^2 = 2\\bigl(1 - k(x, x')\\bigr)
8
+
9
+ for a normalised kernel. Everything else — assign, recentre, repeat — is Lloyd's, and
10
+ is deliberately unchanged so that any difference in the result is attributable to the
11
+ distance and nothing else.
12
+
13
+ The feature map is the argument, exactly as in :class:`~qmlkit.QSVC`: a clustering
14
+ method built on a kernel *is* its embedding.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from typing import Any
21
+
22
+ import numpy as np
23
+ import numpy.typing as npt
24
+
25
+ from qmlkit.core.execute import BackendLike
26
+ from qmlkit.encoding.feature_maps import FeatureMap
27
+ from qmlkit.kernels.matrix import QuantumKernel
28
+
29
+ __all__ = ["QMeans", "QMeansResult"]
30
+
31
+
32
+ @dataclass
33
+ class QMeansResult:
34
+ labels: npt.NDArray[Any]
35
+ centroids: npt.NDArray[Any]
36
+ inertia: float
37
+ n_iterations: int
38
+ history: list[float] = field(default_factory=list)
39
+
40
+ def __repr__(self) -> str:
41
+ return (
42
+ f"QMeansResult(k={len(self.centroids)}, inertia={self.inertia:.6f}, "
43
+ f"iterations={self.n_iterations})"
44
+ )
45
+
46
+
47
+ class QMeans:
48
+ """k-means where the distance comes from a quantum kernel.
49
+
50
+ Parameters
51
+ ----------
52
+ n_clusters
53
+ ``k``.
54
+ feature_map
55
+ The embedding the distance is measured in. This is the only quantum part,
56
+ and swapping it is the entire experiment.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ n_clusters: int = 2,
62
+ feature_map: FeatureMap | None = None,
63
+ max_iterations: int = 50,
64
+ tol: float = 1e-6,
65
+ shots: int | None = None,
66
+ backend: BackendLike = None,
67
+ seed: int | None = None,
68
+ ) -> None:
69
+ if n_clusters < 1:
70
+ raise ValueError("n_clusters must be at least 1")
71
+ self.n_clusters = n_clusters
72
+ self.feature_map = feature_map
73
+ self.max_iterations = max_iterations
74
+ self.tol = tol
75
+ self.shots = shots
76
+ self.backend = backend
77
+ self.seed = seed
78
+ self.centroids_: npt.NDArray[Any] | None = None
79
+ self.labels_: npt.NDArray[Any] | None = None
80
+
81
+ # ---------------------------------------------------------------- distance --
82
+ def _kernel(self, n_features: int) -> QuantumKernel:
83
+ from qmlkit.encoding.feature_maps import AngleFeatureMap
84
+
85
+ fmap = self.feature_map or AngleFeatureMap(n_features, entangle=n_features > 1)
86
+ return QuantumKernel(fmap, shots=self.shots, backend=self.backend, seed=self.seed)
87
+
88
+ def distances(self, X: npt.NDArray[Any], centroids: npt.NDArray[Any]) -> npt.NDArray[Any]:
89
+ r"""``(n_samples, k)`` of :math:`2(1 - k(x, c))`.
90
+
91
+ A kernel with unit diagonal induces a genuine squared distance this way, so
92
+ the assignment step below is the ordinary one — no special-casing.
93
+ """
94
+ kernel = self._kernel(X.shape[1])
95
+ gram = kernel(np.asarray(X, dtype=float), np.asarray(centroids, dtype=float))
96
+ return 2.0 * (1.0 - gram)
97
+
98
+ # -------------------------------------------------------------------- fit --
99
+ def fit(self, X: npt.NDArray[Any], seed: int | None = None) -> QMeansResult:
100
+ data = np.asarray(X, dtype=float)
101
+ if len(data) < self.n_clusters:
102
+ raise ValueError(f"cannot form {self.n_clusters} clusters from {len(data)} samples")
103
+ rng = np.random.default_rng(self.seed if seed is None else seed)
104
+ # k-means++ style start: distinct rows, so two centroids cannot collide
105
+ chosen = rng.choice(len(data), size=self.n_clusters, replace=False)
106
+ centroids = data[chosen].copy()
107
+
108
+ history: list[float] = []
109
+ labels = np.zeros(len(data), dtype=int)
110
+ iteration = 0
111
+ for iteration in range(self.max_iterations): # noqa: B007 - used after the loop
112
+ d = self.distances(data, centroids)
113
+ labels = np.argmin(d, axis=1)
114
+ inertia = float(d[np.arange(len(data)), labels].sum())
115
+ history.append(inertia)
116
+
117
+ moved = centroids.copy()
118
+ for k in range(self.n_clusters):
119
+ members = data[labels == k]
120
+ if len(members):
121
+ moved[k] = members.mean(axis=0)
122
+ # An empty cluster keeps its centroid rather than drifting or
123
+ # collapsing onto another; duplicated points make that reachable.
124
+ shift = float(np.abs(moved - centroids).max())
125
+ centroids = moved
126
+ if shift < self.tol:
127
+ break
128
+
129
+ self.centroids_ = centroids
130
+ self.labels_ = labels
131
+ return QMeansResult(
132
+ labels=labels,
133
+ centroids=centroids,
134
+ inertia=history[-1],
135
+ n_iterations=iteration + 1,
136
+ history=history,
137
+ )
138
+
139
+ def predict(self, X: npt.NDArray[Any]) -> npt.NDArray[Any]:
140
+ if self.centroids_ is None:
141
+ raise ValueError("QMeans must be fitted before predicting")
142
+ return np.argmin(self.distances(np.asarray(X, dtype=float), self.centroids_), axis=1)
143
+
144
+ def fit_predict(self, X: npt.NDArray[Any], seed: int | None = None) -> npt.NDArray[Any]:
145
+ return self.fit(X, seed=seed).labels
146
+
147
+ def __repr__(self) -> str:
148
+ name = type(self.feature_map).__name__ if self.feature_map else "AngleFeatureMap"
149
+ return f"QMeans(n_clusters={self.n_clusters}, feature_map={name})"
@@ -0,0 +1,143 @@
1
+ r"""Hamiltonians to hand to VQE, and an exact answer to check it against.
2
+
3
+ A Hamiltonian here is just a :class:`~qmlkit.core.observables.PauliSum` — the same
4
+ type an expectation value takes — so nothing new has to learn about it. These are
5
+ constructors, not a new class hierarchy.
6
+
7
+ :func:`exact_ground_energy` diagonalises the dense matrix. That is exponential and
8
+ useless past ~14 qubits, which is exactly the point: it is the oracle a variational
9
+ result gets *checked* against on small systems, not a method to compete with.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterable, Sequence
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ import numpy.typing as npt
19
+
20
+ from qmlkit.core.builder import entangler_pairs
21
+ from qmlkit.core.observables import Observable, PauliString, PauliSum, as_sum
22
+
23
+ __all__ = [
24
+ "pauli_hamiltonian",
25
+ "ising_hamiltonian",
26
+ "heisenberg_hamiltonian",
27
+ "max_cut_hamiltonian",
28
+ "hamiltonian_matrix",
29
+ "exact_ground_energy",
30
+ "exact_ground_state",
31
+ ]
32
+
33
+ _PAULI: dict[str, npt.NDArray[Any]] = {
34
+ "I": np.eye(2, dtype=complex),
35
+ "X": np.array([[0, 1], [1, 0]], dtype=complex),
36
+ "Y": np.array([[0, -1j], [1j, 0]], dtype=complex),
37
+ "Z": np.array([[1, 0], [0, -1]], dtype=complex),
38
+ }
39
+
40
+
41
+ def pauli_hamiltonian(terms: Iterable[tuple[str, Sequence[int], float]]) -> PauliSum:
42
+ """Build from ``(paulis, qubits, coefficient)`` triples.
43
+
44
+ >>> pauli_hamiltonian([("ZZ", (0, 1), 1.0), ("X", (0,), -0.5)])
45
+ Z0 Z1 + -0.5*X0
46
+ """
47
+ out: list[PauliString] = []
48
+ for letters, qubits, coeff in terms:
49
+ # A constant term is the identity: no letters and no qubits. Allowed, because
50
+ # dropping it would silently shift every energy the Hamiltonian reports.
51
+ if not letters and not tuple(qubits):
52
+ out.append(PauliString((), float(coeff)))
53
+ continue
54
+ if len(letters) != len(qubits):
55
+ raise ValueError(f"{letters!r} needs {len(letters)} qubits, got {tuple(qubits)}")
56
+ paulis = tuple(
57
+ (int(q), p.upper()) for q, p in zip(qubits, letters, strict=True) if p.upper() != "I"
58
+ )
59
+ out.append(PauliString(tuple(sorted(paulis)), float(coeff)))
60
+ return PauliSum(tuple(out))
61
+
62
+
63
+ def ising_hamiltonian(
64
+ n_qubits: int,
65
+ j: float = 1.0,
66
+ h: float = 1.0,
67
+ edges: Sequence[tuple[int, int]] | None = None,
68
+ pattern: str = "chain",
69
+ ) -> PauliSum:
70
+ r"""Transverse-field Ising model, :math:`H = J\sum Z_iZ_j + h\sum X_i`.
71
+
72
+ The standard first test for any variational eigensolver: it is exactly solvable,
73
+ frustration-free at ``h=0``, and its ground state becomes genuinely entangled as
74
+ ``h`` grows, so a working VQE has to do real work.
75
+ """
76
+ graph = list(edges) if edges is not None else list(entangler_pairs(n_qubits, pattern))
77
+ terms: list[tuple[str, tuple[int, ...], float]] = [("ZZ", (a, b), j) for a, b in graph]
78
+ terms += [("X", (q,), h) for q in range(n_qubits)]
79
+ return pauli_hamiltonian(terms)
80
+
81
+
82
+ def heisenberg_hamiltonian(
83
+ n_qubits: int,
84
+ jx: float = 1.0,
85
+ jy: float = 1.0,
86
+ jz: float = 1.0,
87
+ h: float = 0.0,
88
+ edges: Sequence[tuple[int, int]] | None = None,
89
+ pattern: str = "chain",
90
+ ) -> PauliSum:
91
+ r"""Heisenberg model, :math:`\sum J_\alpha \sigma^\alpha_i\sigma^\alpha_j + h\sum Z_i`."""
92
+ graph = list(edges) if edges is not None else list(entangler_pairs(n_qubits, pattern))
93
+ terms: list[tuple[str, Sequence[int], float]] = []
94
+ for a, b in graph:
95
+ for letter, coupling in (("XX", jx), ("YY", jy), ("ZZ", jz)):
96
+ if coupling:
97
+ terms.append((letter, (a, b), coupling))
98
+ terms += [("Z", (q,), h) for q in range(n_qubits) if h]
99
+ return pauli_hamiltonian(terms)
100
+
101
+
102
+ def max_cut_hamiltonian(edges: Sequence[tuple[int, int]], n_qubits: int | None = None) -> PauliSum:
103
+ r"""MaxCut cost, :math:`\tfrac12\sum_{(i,j)\in E}(Z_iZ_j - 1)`.
104
+
105
+ Minimising this maximises the cut, and its ground-state energy is
106
+ ``-(number of edges cut)``. The constant is kept rather than dropped so the
107
+ energy VQE or QAOA reports *is* the negated cut size, with nothing to add back.
108
+ """
109
+ if not edges:
110
+ raise ValueError("MaxCut needs at least one edge")
111
+ _ = n_qubits # width comes from the edges themselves; kept for a symmetric API
112
+ terms: list[tuple[str, Sequence[int], float]] = [("ZZ", (a, b), 0.5) for a, b in edges]
113
+ terms.append(("", (), -0.5 * len(edges)))
114
+ return pauli_hamiltonian(terms)
115
+
116
+
117
+ def hamiltonian_matrix(obs: Observable, n_qubits: int) -> npt.NDArray[Any]:
118
+ """Dense ``2**n x 2**n`` matrix. Exponential — for checking, not for running."""
119
+ if n_qubits > 14:
120
+ raise ValueError(
121
+ f"a dense matrix for {n_qubits} qubits needs {4**n_qubits * 16 / 1e9:.0f} GB; "
122
+ "this function exists to verify small cases, not to solve large ones"
123
+ )
124
+ dim = 2**n_qubits
125
+ total = np.zeros((dim, dim), dtype=complex)
126
+ for term in as_sum(obs).terms:
127
+ letters = dict(term.paulis)
128
+ matrix = np.eye(1, dtype=complex)
129
+ for qubit in range(n_qubits):
130
+ matrix = np.kron(matrix, _PAULI[letters.get(qubit, "I")])
131
+ total += complex(term.coeff) * matrix
132
+ return total
133
+
134
+
135
+ def exact_ground_energy(obs: Observable, n_qubits: int) -> float:
136
+ """Lowest eigenvalue, by dense diagonalisation. The oracle, not the method."""
137
+ return float(np.linalg.eigvalsh(hamiltonian_matrix(obs, n_qubits))[0])
138
+
139
+
140
+ def exact_ground_state(obs: Observable, n_qubits: int) -> tuple[float, npt.NDArray[Any]]:
141
+ """Lowest eigenvalue and its eigenvector."""
142
+ values, vectors = np.linalg.eigh(hamiltonian_matrix(obs, n_qubits))
143
+ return float(values[0]), np.asarray(vectors[:, 0])