Linear Systems with HHL
The Harrow-Hassidim-Lloyd (HHL) algorithm solves a linear system Ax = b on a
quantum computer. The eigenkit implementation has two modes:
mode |
How the circuit is built | Runs on hardware? |
|---|---|---|
"reference" (default) |
The eigenvalues are found classically (eigvalsh), e^{iAt} is applied as a dense matrix from expm, and the evolution time is tuned to the known spectrum. |
❌ No, it runs on a simulator only. |
"honest" |
There is no eigendecomposition, e^{iAt} is Trotterised into elementary gates, and the time is fixed from a Gershgorin bound that is cheap to compute. |
✅ Yes, the circuit can be built from elementary gates. |
"reference" mode is fast and exact. However, it does O(N³) classical work, and it
applies a 2ⁿ × 2ⁿ unitary that is given only as a matrix, so it gives no quantum
speedup. "honest" mode removes all three shortcuts, so its circuit is one that you
could, in principle, run on real hardware.
This example runs on provider="numpy", which is the default of HHL, so it needs no
optional extra. The figures need matplotlib. The whole page runs in about ten
seconds. See the HHL chapter for the construction, and the
Algorithms guide for the interface.
import warnings
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from eigenkit import HHL
from eigenkit.datasets import STYLE_COLORS, rc_params
# Honest mode is a pure-gate simulation; the numpy backend keeps it fast. Each
# figure below is drawn twice, so the site can serve whichever theme matches the
# reader's.
PROVIDER = "numpy"
Solving small systems in honest mode
We solve a few textbook systems with mode="honest", and compare each answer with the
classical one. "honest" mode requires n_eigenvalue_qubits >= 2, because the clock
register must be two-sided to represent negative eigenvalues.
From here on, every number comes from the circuit of "honest" mode. "reference"
mode appears only once more, in the section on how "honest" mode works below. There,
its gate counts are the baseline that "honest" mode is defined against. It is not
shown as an alternative to run.
None of the examples below chooses n_eigenvalue_qubits by hand. In the printed rows,
n_l is the number of clock qubits. P_readout is the rate at which a device
post-selects, and it falls by about four times for each added clock qubit. The helper
below therefore chooses the register: it grows the register until the solver stops
warning and the residual is good enough, and then it stops. The section on precision
below is the sweep that shows why it should stop there.
def cosine(u, v):
u, v = np.asarray(u).ravel(), np.asarray(v).ravel()
return abs(np.vdot(u, v)) / (np.linalg.norm(u) * np.linalg.norm(v) + 1e-15)
def solve_at_chosen_clock(A, b, target=0.03, n_l_max=7, **kwargs):
"""Solve in honest mode, letting the answer choose the clock register.
`n_eigenvalue_qubits` is the one knob honest mode cannot derive for itself.
The resolution floor `C` has to clear the smallest eigenvalue of `A`, and
the smallest eigenvalue is precisely what the honest path refuses to
compute. Two things it may read instead: the warnings the solver raises for
itself, and the residual the result already carries: `‖Ax − b‖/‖b‖` is an
`O(N²)` check on the answer, not an `O(N³)` look at the spectrum.
So grow the register from 2 and stop at the first size that raises no
warning and lands inside `target`. Stopping matters as much as starting:
each extra clock qubit costs about fourfold in post-selection rate, and
the precision section shows the residual does not fall monotonically to pay
for it. The smallest register that is good enough is the right one, not the
largest that fits.
"""
A, b = np.asarray(A, float), np.asarray(b, float)
x_true = np.linalg.solve(A, b)
for n_l in range(2, n_l_max + 1):
# A warning here is the solver telling us this register is too coarse,
# which is the answer to the question being asked, not a problem to
# report, so it is caught rather than printed.
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = HHL(
n_eigenvalue_qubits=n_l, provider=PROVIDER, mode="honest", **kwargs
).solve(A, b)
good = not caught and result.residual <= target
note = f"{len(caught)} warning(s)" if caught else ""
line = (
f" n_l={n_l}: resid={result.residual:.4f} "
f"cos={cosine(result.solution, x_true):.4f} "
f"P_readout={result.readout_probability:.2e} "
f"1 in {round(1 / max(result.readout_probability, 1e-12)):<4d} {note}"
+ (" <- chosen" if good else "")
)
# Rstrip, because `trim trailing whitespace` in pre-commit strips the
# page and cannot strip the cell, and the two then never match again.
print(line.rstrip())
if good:
if x_true.size <= 8: # a wider vector wraps and reads as noise
print(f" classical x = {np.round(x_true, 4)}")
print(f" honest x = {np.round(result.solution, 4)}")
return n_l, result
raise RuntimeError(
f"no register up to n_l={n_l_max} met target={target}; the system is "
"worse conditioned than this notebook's examples"
)
A 2×2 diagonal system
A diagonal A decomposes into Z strings only, and these commute with each other, so
its Trotterisation is exact: even trotter_steps=1 gives a clean answer. The
residual that is left comes from the resolution of the clock, not from Trotter error.
This is why the selector below can trade it against the post-selection rate at all.
n_l=2: resid=0.5586 cos=0.7417 P_readout=5.76e-01 1 in 2 1 warning(s)
n_l=3: resid=0.0508 cos=0.9991 P_readout=2.37e-01 1 in 4
n_l=4: resid=0.0125 cos=0.9999 P_readout=4.90e-02 1 in 20 <- chosen
classical x = [0.5 1. ]
honest x = [0.5062 0.9874]
A system whose Trotterisation is only approximate
The eigenvalues are 1 ± √2, so the condition number is κ(A) ≈ 5.83, and the
spectrum is indefinite (it has eigenvalues of both signs). What matters here is that
X and Z anticommute: the Trotter product e^{iXτ} e^{iZτ} is not
e^{i(X+Z)τ}. This is therefore the first example on this page where
trotter_steps changes the answer at all.
The more obvious choice, [[2, 1], [1, 2]], would not work as an example. It equals
2I + X, which has exactly one Pauli term that is not the identity. With a single
term, the Trotter product is the exact exponential for any number of steps, so
trotter_steps has no effect there. The cell below prints both decompositions, so that
you can see the difference. An off-diagonal entry is not the same thing as Pauli terms
that do not commute.
We then let the selector choose a register at the default trotter_steps="auto", and
solve again on that same register with a single Trotter step, so that the Trotter grid
is the only thing that changes.
from eigenkit.algorithms._hamiltonian_sim import pauli_decompose
A_1b = np.array([[2.0, 1.0], [1.0, 0.0]])
b_1b = np.array([1.0, 0.0])
# The two-term decomposition that disqualifies [[2,1],[1,2]] as an example here,
# and the three-term one that makes [[2,1],[1,0]] a real test of trotter_steps.
for M in (np.array([[2.0, 1.0], [1.0, 2.0]]), A_1b):
print(
f"{M.tolist()} = "
+ " + ".join(f"{c.real:+.3g}*{s}" for c, s in pauli_decompose(M))
)
# The register is chosen at the default trotter_steps="auto" ...
print('\ntrotter_steps="auto" (the default)')
n_l_1b, auto_1b = solve_at_chosen_clock(A_1b, b_1b)
# ... and then held fixed, so the only thing that changes below is the grid.
print("\ntrotter_steps=1 (one step for the whole evolution), same register")
one_step = HHL(
n_eigenvalue_qubits=n_l_1b, provider=PROVIDER, mode="honest", trotter_steps=1
).solve(A_1b, b_1b)
print(
f" n_l={n_l_1b}: resid={one_step.residual:.4f} "
f"cos={cosine(one_step.solution, np.linalg.solve(A_1b, b_1b)):.4f} "
f"P_readout={one_step.readout_probability:.2e}"
)
print(f" honest x = {np.round(one_step.solution, 4)}")
[[2.0, 1.0], [1.0, 2.0]] = +2*I + +1*X
[[2.0, 1.0], [1.0, 0.0]] = +1*I + +1*X + +1*Z
trotter_steps="auto" (the default)
n_l=2: resid=0.3827 cos=0.3825 P_readout=6.64e-01 1 in 2 2 warning(s)
n_l=3: resid=0.3457 cos=0.5810 P_readout=1.48e-01 1 in 7 2 warning(s)
n_l=4: resid=0.0003 cos=1.0000 P_readout=1.67e-01 1 in 6 <- chosen
classical x = [0. 1.]
honest x = [-3.0000e-04 1.0006e+00]
trotter_steps=1 (one step for the whole evolution), same register
RuntimeWarning: HHL(mode='honest'): the Trotter drift bound ‖A_eff − A‖ ≲ 0.42 exceeds C/2 = 0.214 at trotter_steps=1, trotter_order=2. The circuit is then exact HHL for a perturbed A_eff: an extremal eigenvalue can be captured by the sign-ambiguous Nyquist bucket, and the eigenvectors rotate, corrupting the direction of the solution without changing its spectrum. Use trotter_steps=6 or 'auto'.
n_l=4: resid=0.2412 cos=0.8964 P_readout=7.07e-02
honest x = [0.2341 0.4736]
At trotter_steps=1, the residual is 0.2412, and the cosine with x* drops to
0.8964. trotter_steps="auto" picks 6 steps, and reaches a residual of 0.0003
with a cosine of 1.0000. The single-step run also raises a RuntimeWarning. The
drift bound ‖A_eff − A‖ ≲ 0.42, where A_eff is the Hamiltonian that the Trotter
circuit actually simulates, exceeds C/2 = 0.214, where C is the rotation constant.
Quantum phase estimation (QPE) then measures a perturbed Hamiltonian, and not A. This
warning is the reason for "auto". The selector solves the same bound for the smallest
number of steps that stays below trotter_tolerance · C (the default tolerance is
0.25). When you set the number of steps by hand, the warning gives the number that
the selector would need (6).
An indefinite system, with a negative eigenvalue
The eigenvalues are ±1. The two-sided clock register resolves the sign of each
eigenvalue, and the controlled RY inversion gives 1/λ the correct sign. Both
eigenvalues have modulus 1, so two clock qubits already place them exactly on the
grid of buckets. This is the best case on the whole page, and it is worth seeing
once.
_ = solve_at_chosen_clock(
np.array([[0.0, 1.0], [1.0, 0.0]]), np.array([1.0, 1.0]), trotter_steps=1
)
n_l=2: resid=0.0000 cos=1.0000 P_readout=1.00e+00 1 in 1 <- chosen
classical x = [1. 1.]
honest x = [1. 1.]
Beyond 2×2: a 4×4 and an 8×8
Nothing so far requires the system to be small. The three cases above are 2 × 2 only
so that their Pauli decompositions fit in a line of text. Here is the
finite-difference matrix at N = 4 and N = 8, solved from start to finish in
"honest" mode, with the register chosen in the same way.
def tridiagonal_system(dim):
"""The finite-difference matrix, with b a unit load on the first node."""
A = 2.0 * np.eye(dim) - np.eye(dim, k=1) - np.eye(dim, k=-1)
b = np.zeros(dim)
b[0] = 1.0
return A, b
for dim in (4, 8):
A_n, b_n = tridiagonal_system(dim)
print(
f"{dim}x{dim} tridiagonal: kappa={np.linalg.cond(A_n):.1f}, "
f"{len(pauli_decompose(A_n))} Pauli terms, "
f"{dim.bit_length() - 1} state qubits"
)
solve_at_chosen_clock(A_n, b_n)
4x4 tridiagonal: kappa=9.5, 4 Pauli terms, 2 state qubits
n_l=2: resid=0.7017 cos=0.2905 P_readout=2.98e-01 1 in 3 2 warning(s)
n_l=3: resid=0.3643 cos=0.5031 P_readout=4.66e-01 1 in 2 2 warning(s)
n_l=4: resid=0.1959 cos=0.9339 P_readout=1.45e-01 1 in 7
n_l=5: resid=0.0319 cos=0.9996 P_readout=7.89e-02 1 in 13
n_l=6: resid=0.0086 cos=1.0000 P_readout=1.94e-02 1 in 51 <- chosen
classical x = [0.8 0.6 0.4 0.2]
honest x = [0.7965 0.593 0.3972 0.2006]
8x8 tridiagonal: kappa=32.2, 8 Pauli terms, 3 state qubits
n_l=2: resid=0.7026 cos=0.2008 P_readout=2.97e-01 1 in 3 2 warning(s)
n_l=3: resid=0.3671 cos=0.3729 P_readout=3.73e-01 1 in 3 2 warning(s)
n_l=4: resid=0.1829 cos=0.5478 P_readout=1.62e-01 1 in 6 1 warning(s)
n_l=5: resid=0.1450 cos=0.7137 P_readout=4.64e-02 1 in 22 1 warning(s)
n_l=6: resid=0.0190 cos=0.9997 P_readout=3.67e-02 1 in 27 <- chosen
classical x = [0.8889 0.7778 0.6667 0.5556 0.4444 0.3333 0.2222 0.1111]
honest x = [0.8694 0.7391 0.6207 0.515 0.4105 0.3047 0.2055 0.1027]
Both systems stop at six clock qubits, with a cosine of 1.0000 for the 4 × 4 and
0.9997 for the 8 × 8. The 8 × 8 has κ = 32, which is five times the condition
number of any of the 2 × 2 examples, and it reaches a residual of 0.019 with one
usable preparation in 27. The 8 × 8 system is then a circuit of 10 qubits (3 state
qubits, 6 clock qubits and 1 ancilla), and the whole search takes seconds of
simulation.
Read the rejected rows, because they show why the warning check is needed. The 8 × 8
at n_l = 5 has a residual of 0.145, which is bad but not obviously fatal, while its
cosine is 0.71. This means that the direction of the answer is wrong, and not only
its scale, which is exactly what a residual cannot tell you. The solver flags this row
itself: the smallest eigenvalue, 0.121, is below half the resolution of the clock.
These rows are also cheaper (1 in 22 against 1 in 27), so a selector based on the
post-selection rate alone would have taken one of them. The warning is what rules them
out.
Under the hood: what makes honest mode different
Three things distinguish "honest" mode from "reference" mode. We inspect each of
them on the following matrix:
from eigenkit.algorithms._hamiltonian_sim import pauli_decompose
A = np.array([[2.0, 1.0], [1.0, 3.0]])
# (i) A is written as a sum of Pauli strings, the building block of gate-level
# Hamiltonian simulation. No eigendecomposition involved.
terms = pauli_decompose(A)
print("Pauli decomposition of A:")
for coeff, pstring in terms:
print(f" {coeff.real:+.3f} · {pstring}")
(i) Parameters without the spectrum. The "honest" path never calls eigvalsh.
The evolution time comes from a Gershgorin upper bound on ‖A‖, which is the largest
sum of absolute values over the rows. It costs O(nnz), where nnz is the number of
non-zero entries of A. The rotation constant C comes from the resolution of the
clock register. No eigenvalues are needed.
from eigenkit.algorithms.hhl import (
_gershgorin_spectral_radius,
_honest_qpe_params,
)
n_l = 5
lam_max_bound = _gershgorin_spectral_radius(A)
t, C, _ = _honest_qpe_params(A, n_l)
print(
f"Gershgorin bound on |λ|_max : {lam_max_bound:.4f} "
f"(true = {abs(np.linalg.eigvalsh(A)).max():.4f})"
)
print(
f"evolution time t : {t:.4f} (fixed; t·bound = {t * lam_max_bound:.4f} < π)"
)
print(
f"rotation constant C : {C:.4f} (= register resolution, no spectrum used)"
)
Gershgorin bound on |λ|_max : 4.0000 (true = 3.6180)
evolution time t : 0.7363 (fixed; t·bound = 2.9452 < π)
rotation constant C : 0.2667 (= register resolution, no spectrum used)
(ii) Elementary gates throughout. In "reference" mode, e^{iAt} enters QPE as a
dense ControlledQubitUnitary that carries the full 2ⁿ × 2ⁿ matrix. It is a black
box with no decomposition into hardware gates. "honest" mode replaces it with a
HamiltonianQPEBlock built from H, RZ, controlled-NOT (CNOT), CPHASE,
PhaseShift and SWAP gates (plus RX when the decomposition contains Y terms,
which this A does not). In both modes, the eigenvalue inversion is a uniformly
controlled rotation: 2^n_l RY gates and 2^n_l CNOT gates, with no ancillas and
no multi-controlled gate.
The cell below counts the gates twice: first in the QPE sub-block on its own, and then in the assembled HHL circuit. The second count describes the circuit that you would send to a device. The reason to check it separately is that a clean sub-block says nothing about the whole circuit.
from collections import Counter
import scipy.linalg
from eigenkit.algorithms.hhl import _compute_rotation_angles
from eigenkit.core.blocks import (
HamiltonianQPEBlock,
QPEBlock,
QubitUnitary,
assemble,
)
# (a) the QPE sub-block on its own
# Reference QPE: dense oracle e^{iAt} raised to powers.
U = scipy.linalg.expm(1j * A * t)
ref_oracle = assemble([QubitUnitary(U, wires=(0,))])
ref_gates = QPEBlock(ref_oracle, n_clock=n_l).build(n_l + 1).gates
# Honest QPE: Trotterised into elementary gates.
hon_gates = HamiltonianQPEBlock(terms, n_clock=n_l, time=t, steps=6).build(n_l + 1).gates
print("reference QPE gate counts:", dict(Counter(g.name for g in ref_gates)))
print("honest QPE gate counts:", dict(Counter(g.name for g in hon_gates)))
dense = [g for g in ref_gates if g.name == "ControlledQubitUnitary"][0]
print(
f"\nreference evolution oracle is a dense {len(dense.data[0])}×{len(dense.data[0])} "
f"controlled unitary (a hardware black box)"
)
print(
"honest QPE *block* contains ControlledQubitUnitary:",
any(g.name == "ControlledQubitUnitary" for g in hon_gates),
)
# (b) the assembled HHL circuit
# The block above is only the evolution stage. The inversion is the other half, and
# a sub-block being free of dense gates says nothing about the whole circuit, so
# assemble both modes and count what actually ships.
n_b = 1
b_unit = np.array([1.0, 0.0])
angles = _compute_rotation_angles(2**n_l, t, C, lam_max_bound, 0.01)
specs = {}
for mode, kwargs in (("reference", {}), ("honest", {"trotter_steps": 6})):
solver = HHL(n_eigenvalue_qubits=n_l, provider=PROVIDER, mode=mode, **kwargs)
specs[mode] = solver._build_circuit_spec(A, b_unit, n_b, n_l, t, angles)
counts = dict(Counter(g.name for g in specs[mode].gates))
print(f"\nassembled {mode:9s} HHL circuit: {counts}")
dense_names = {"QubitUnitary", "ControlledQubitUnitary"}
for mode, spec in specs.items():
n_dense = sum(1 for g in spec.gates if g.name in dense_names)
print(f"{mode:>9s}: {n_dense} dense unitary-matrix gates")
n_ry = sum(1 for g in specs["honest"].gates if g.name == "RY")
print(
f"\nhonest inversion: {n_ry} RY gates = 2^n_l = {2**n_l}, each elementary, "
f"paired with {n_ry} CNOTs"
)
reference QPE gate counts: {'H': 10, 'ControlledQubitUnitary': 5, 'SWAP': 2, 'CPHASE': 10}
honest QPE gate counts: {'H': 382, 'PhaseShift': 186, 'RZ': 744, 'CNOT': 744, 'SWAP': 2, 'CPHASE': 10}
reference evolution oracle is a dense 2×2 controlled unitary (a hardware black box)
honest QPE *block* contains ControlledQubitUnitary: False
assembled reference HHL circuit: {'AmplitudeEncoding': 1, 'H': 20, 'ControlledQubitUnitary': 10, 'SWAP': 4, 'CPHASE': 20, 'RY': 32, 'CNOT': 32}
assembled honest HHL circuit: {'AmplitudeEncoding': 1, 'H': 1508, 'PhaseShift': 744, 'RZ': 2976, 'CNOT': 3008, 'SWAP': 4, 'CPHASE': 20, 'RY': 32}
reference: 10 dense unitary-matrix gates
honest: 0 dense unitary-matrix gates
honest inversion: 32 RY gates = 2^n_l = 32, each elementary, paired with 32 CNOTs
The two counts show that both stages are elementary. The assembled circuit is the claim
that matters: it has no ControlledQubitUnitary and no QubitUnitary at all,
against 10 in the "reference" circuit. These 10 gates are exactly the oracle powers
e^{iAt·2ᵏ}. The inversion is shared between the two modes, so it is elementary in
both, and the dense evolution is the only thing that still separates them.
The evolution is the larger part. The QPE block of "honest" mode adds only H, RZ,
CNOT, PhaseShift, CPHASE and SWAP gates, against the five 2 × 2 oracle powers
of the "reference" block, each of which is a black box with no decomposition into
hardware gates. The inversion then adds 2^n_l = 32 RY gates and 32 CNOT gates.
This is the uniformly controlled decomposition of
Möttönen et al., which applies a different
RY(θ_j) exactly for each of the 32 clock buckets, with one RY and one CNOT for
each step of a Gray-code walk. Written out literally, as one rotation controlled by
n_l qubits for each bucket, the same stage would be 31 multi-controlled gates.
Two claims about this are easy to confuse:
- The circuit can be built from elementary gates. Every gate above is one that a
device implements directly, and the count is
Θ(2^n_l). The ladder of controlled evolutions in QPE already costs this much, and by the no-fast-forwarding theorem, no HHL based on QPE avoids it. - The description is not compact. The
2^n_langlesθ_j = 2 arcsin(C/λ̃_j)are listed bucket by bucket instead of computed, so the classical description of the circuit has sizeΘ(2^n_l), and this is not a uniform family of circuits of polynomial size. Making it uniform means evaluatingarcsin(1/λ)with quantum arithmetic on the clock register. Circuits for the functions that this needs, including the arcsine, are given by Häner et al. 2018 (arXiv:1805.12445).
Trotter accuracy: trotter_steps and trotter_order
For an A whose Pauli terms do not commute, the Trotter product approximates
e^{iAt} with an error that shrinks as trotter_steps grows, and that shrinks
faster for the symmetric second order. With the default trotter_steps="auto", the
number of steps is chosen so that this error stays below the limit set by the finite
resolution of the clock. This is why the anticommuting example above needed
trotter_steps=1 to show the effect of this setting at all. To see how the two orders
scale, we measure the Trotter error of the evolution e^{iAt} directly, because it
is the quantity that ultimately governs the accuracy of "honest" mode. We use a
2-qubit A whose terms are strongly non-commuting.
import scipy.linalg
from eigenkit import Circuit
from eigenkit.algorithms._hamiltonian_sim import pauli_decompose
from eigenkit.core.blocks import TrotterBlock
from eigenkit.core.ir import CircuitSpec
def block_unitary(gates, n):
"""Dense unitary of a gate list via the statevector backend (qubit-0-MSB)."""
spec = CircuitSpec(n_qubits=n, n_weights=0, n_inputs=0, gates=tuple(gates))
circ = Circuit(spec, provider=PROVIDER)
dim = 2**n
cols = [
circ.statevector(np.zeros(0), initial_state=np.eye(dim)[j].astype(complex))
for j in range(dim)
]
return np.column_stack(cols)
A_t = np.array(
[
[1.0, 0.7, 0.0, 0.3],
[0.7, -0.5, 0.4, 0.0],
[0.0, 0.4, 0.8, 0.6],
[0.3, 0.0, 0.6, -1.0],
]
)
n_state, t_t = 2, 2.0
terms_t = pauli_decompose(A_t)
# TrotterBlock skips the identity term (an unobservable global phase), so we
# compare against the traceless part of A.
A_traceless = A_t - np.trace(A_t) / A_t.shape[0] * np.eye(A_t.shape[0])
exact = scipy.linalg.expm(1j * A_traceless * t_t)
step_grid = [1, 2, 4, 8, 16, 32, 64]
err = {1: [], 2: []}
for order in (1, 2):
for s in step_grid:
U = block_unitary(
TrotterBlock(terms_t, time=t_t, steps=s, order=order).build(n_state).gates,
n_state,
)
err[order].append(float(np.linalg.norm(U - exact, 2)))
print(
f"order {order}: "
+ " ".join(f"{s}:{v:.1e}" for s, v in zip(step_grid, err[order]))
)
order 1: 1:1.7e+00 2:9.6e-01 4:4.9e-01 8:2.5e-01 16:1.2e-01 32:6.1e-02 64:3.1e-02
order 2: 1:1.0e+00 2:2.8e-01 4:7.0e-02 8:1.7e-02 16:4.3e-03 32:1.1e-03 64:2.7e-04
The figure shows the two rates on a log-log axis, where a power law is a straight line whose slope is its exponent:
# Text inside matplotlib artists needs care here: the docs theme renders serif text
# in cmr10, TeX's OT1-encoded font. It has no glyph at all for en dash, U+2212
# minus, or unicode sub/superscripts, and it maps ASCII "|", "_" and "^" to em
# dash, dot accent and circumflex. So keep artist text to plain words with ASCII
# hyphens, and put every mathematical symbol inside $...$ mathtext, which uses the
# correctly encoded cm fonts.
for theme in ("light", "dark"):
with matplotlib.rc_context(rc_params(theme=theme)):
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(
step_grid,
err[1],
"o-",
label="order 1 (Lie-Trotter)",
color=STYLE_COLORS[0],
)
ax.plot(
step_grid,
err[2],
"s-",
label="order 2 (symmetric)",
color=STYLE_COLORS[3],
)
ax.set_xscale("log", base=2)
ax.set_yscale("log")
ax.set_xlabel("trotter steps")
ax.set_ylabel(r"$\| U_{\mathrm{trotter}} - e^{iAt} \|_2$")
ax.set_title(r"Honest HHL: Trotter error of the evolution $e^{iAt}$")
ax.legend()
fig.tight_layout()
fig.savefig(f"trotter_error_{theme}.png", bbox_inches="tight")
if theme == "light":
plt.show()
else:
plt.close(fig)

The order-1 error falls as 1/steps (each doubling halves it), while the order-2 error
falls as 1/steps² (each doubling divides it by four). trotter_order=2 therefore
reaches a target accuracy with far fewer steps. At trotter_steps=64, the two errors
are 3.1e-02 and 2.7e-04, a factor of 115.
You do not normally set trotter_steps by hand. "auto" solves a drift bound for the
number of steps that keeps ‖A_eff − A‖ below trotter_tolerance × C. Lower
trotter_tolerance to get a more accurate and deeper circuit. At order 2, the number
of steps grows only as 1/√tolerance.
Precision, and what post-selection costs
n_eigenvalue_qubits (n_l) sets the resolution of the clock. In "honest" mode, it
also sets the rotation constant C = 2π/(t · 2^n_l), which is the smallest eigenvalue
that the register can resolve. A smaller C handles systems with worse conditioning,
but it lowers the post-selection probability, which scales as (C/λ)². This section
makes the trade-off explicit.
Two probabilities come back, and they are not the same number. success_probability
is the weight on ancilla = |1⟩, summed over all clock states.
readout_probability is the weight on the joint event ancilla = |1⟩ and
clock = |0…0⟩. This is the slice that the solution is actually read from, and the
rate at which a device would post-select. Use the second one to plan the number of
shots; the first one overstates it.
We sweep n_l on A = [[3, 1], [1, 2]] (κ ≈ 2.62) with the default
trotter_steps="auto". Each solve is repeated once as a control, at
trotter_tolerance=0.01, which is a Trotter target 25 times tighter. If the control
follows the series, the error that remains is not Trotter error, and the off-grid
diagnostic shows what it is instead.
A_p = np.array([[3.0, 1.0], [1.0, 2.0]])
b_p = np.array([1.0, 0.0])
x_true_p = np.linalg.solve(A_p, b_p)
n_l_grid = [2, 3, 4, 5, 6]
res_p, cos_p, prob_p, read_p, res_fine = [], [], [], [], []
for n_l in n_l_grid:
kw = dict(n_eigenvalue_qubits=n_l, provider=PROVIDER)
# The series itself. This is the one solve left free to warn.
r = HHL(**kw, mode="honest").solve(A_p, b_p)
# Diagnostic only: the solver never calls eigvalsh in honest mode. How far
# off the clock's bucket grid does each eigenvalue land? 0 = exactly on grid,
# 0.5 = worst case.
t_h, _, _ = _honest_qpe_params(A_p, n_l)
grid_pos = 2**n_l * np.abs(np.linalg.eigvalsh(A_p)) * t_h / (2 * np.pi)
off_grid = np.abs(grid_pos - np.round(grid_pos))
# The control: the same solve against a 25x tighter Trotter target. It
# would repeat the warnings the series has already raised, so it is
# silenced: two copies of one warning read as two problems.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
r_fine = HHL(**kw, mode="honest", trotter_tolerance=0.01).solve(A_p, b_p)
res_p.append(r.residual)
cos_p.append(cosine(r.solution, x_true_p))
prob_p.append(r.success_probability)
read_p.append(r.readout_probability)
res_fine.append(r_fine.residual)
print(
f"n_l={n_l}: residual={r.residual:.4f} cos={cos_p[-1]:.4f} "
f"P_ancilla={r.success_probability:.2e} P_readout={r.readout_probability:.2e}"
f" | off-grid: {np.round(off_grid, 2)}"
)
print(f" control: trotter_tolerance=0.01 -> {r_fine.residual:.4f}")
RuntimeWarning: HHL(mode='honest'): σ_min ≈ 1.38 is below half the resolution floor, C/2 = 2, so it rounds into the rotation-free bucket 0, so that component of b is attenuated or dropped — and it carries the largest 1/λ weight, so it is what the direction of x depends on most. At n_eigenvalue_qubits=2: raise n_eigenvalue_qubits to ≥ 3.
RuntimeWarning: HHL: relative residual ‖Ax−b‖/‖b‖ = 0.491 — a rough answer, and with κ ≈ 3 the direction of x can be off by κ× that (a small residual bounds the backward error, not the forward one). Raise n_eigenvalue_qubits; in honest mode a finer Trotter grid is the other usual cause.
n_l=2: residual=0.4913 cos=0.6116 P_ancilla=7.87e-01 P_readout=6.91e-01 | off-grid: [0.35 0.1 ]
control: trotter_tolerance=0.01 -> 0.4913
n_l=3: residual=0.0640 cos=0.9982 P_ancilla=3.89e-01 P_readout=3.53e-01 | off-grid: [0.04 0.29]
control: trotter_tolerance=0.01 -> 0.0640
n_l=4: residual=0.0099 cos=1.0000 P_ancilla=9.01e-02 P_readout=6.40e-02 | off-grid: [0.42 0.33]
control: trotter_tolerance=0.01 -> 0.0099
n_l=5: residual=0.0371 cos=0.9994 P_ancilla=1.82e-02 P_readout=1.43e-02 | off-grid: [0.18 0.43]
control: trotter_tolerance=0.01 -> 0.0371
n_l=6: residual=0.0079 cos=1.0000 P_ancilla=3.80e-03 P_readout=3.25e-03 | off-grid: [0.29 0.04]
control: trotter_tolerance=0.01 -> 0.0078
The figure has three panels: what the answer is worth, whether its direction is right, and what reading it costs.
# Plain words plus $...$ mathtext only; see the note above the Trotter figure.
# The residual label is also set below the rc default size: at 16 pt it is
# taller than its panel and spills off the saved figure.
for theme in ("light", "dark"):
with matplotlib.rc_context(rc_params(theme=theme)):
fig, ax = plt.subplots(1, 3, figsize=(14, 4))
ax[0].plot(n_l_grid, res_p, "o-", color=STYLE_COLORS[0], lw=2, label="default")
ax[0].plot(
n_l_grid,
res_fine,
"x:",
color=STYLE_COLORS[2],
lw=2,
label="tighter Trotter target",
)
ax[0].set_xlabel(r"clock qubits $n_l$")
ax[0].set_ylabel(r"residual $\|Ax - b\| / \|b\|$", fontsize=13)
ax[0].set_title("Residual, and it wobbles")
ax[0].set_yscale("log")
ax[0].legend(fontsize=9)
ax[1].plot(n_l_grid, cos_p, "s-", color=STYLE_COLORS[5], lw=2)
ax[1].set_xlabel(r"clock qubits $n_l$")
ax[1].set_ylabel(r"cosine similarity with $x^*$", fontsize=13)
ax[1].set_title("Direction accuracy")
ax[1].set_ylim(0, 1.05)
ax[2].plot(
n_l_grid, prob_p, "^-", color=STYLE_COLORS[1], lw=2, label="ancilla only"
)
ax[2].plot(
n_l_grid, read_p, "v--", color=STYLE_COLORS[6], lw=2, label="ancilla+clock"
)
ax[2].set_xlabel(r"clock qubits $n_l$")
ax[2].set_ylabel("post-selection probability", fontsize=13)
ax[2].set_title("Post-selection cost")
ax[2].set_yscale("log")
ax[2].legend(fontsize=11)
fig.suptitle("Honest HHL: precision and the post-selection trade-off", y=1.02)
fig.tight_layout()
fig.savefig(f"precision_{theme}.png", bbox_inches="tight")
if theme == "light":
plt.show()
else:
plt.close(fig)

The residual improves overall, but not monotonically. The measured residuals across
n_l = 2…6 are 0.4913, 0.0640, 0.0099, 0.0371 and 0.0079. Two clock qubits
are not enough to represent the spectrum at all: the residual is 0.49, the cosine is
0.61, and solve() says so twice. The first warning reports that four clock buckets
cannot resolve this κ, and names the n_eigenvalue_qubits that can. The second
warning flags the residual itself as rough. By n_l = 6, the residual is 62 times
smaller. However, n_l = 5 is 3.7 times worse than n_l = 4, and the plot shows
this dip instead of a straight line.
This is not Trotter error, and the control shows it. At trotter_tolerance=0.01, a
Trotter target 25 times tighter, every point of the sweep comes back within 0.0001
of the default (n_l = 5 returns the same 0.0371). The Trotter grid that the
selector chose was therefore never the limiting factor.
The cause is the readout projection. The solution is read from the clock = |0…0⟩
slice, and this slice weights each eigenvalue by how well it fits the grid of buckets.
The weight is exact on the grid, and up to about 9 % low at an offset of half a bucket
(the shortfall is largest for an eigenvalue in the lowest buckets). The pattern changes
as eigenvalues move on and off the grid when n_l changes, and the off-grid column
tracks it: 0.42/0.33 at n_l = 4, 0.18/0.43 at n_l = 5 (close to the
worst case), and 0.29/0.04 at n_l = 6 (almost on the grid). A small irregular
variation of the residual as n_l grows is therefore expected by construction. The
right way to read the trend is its envelope, not the difference from one point to the
next.
The variation comes from the evolution time. With no spectrum to look at, the
"honest" path takes t from the Gershgorin bound, which is 4.0 here against a true
|λ|_max of 3.618. Where the eigenvalues fall between the centres of the buckets is
therefore not chosen: it is whatever this t and this n_l produce. The off-grid
column shows where they fall, and it changes with n_l for reasons that have nothing
to do with an improvement in accuracy. This is the one real cost of not diagonalising
A, and it is a cost in the alignment of the buckets, not in the evolution. The
trotter_tolerance control above has already shown that the Trotterisation is not
what limits the answer.
The post-selection panel shows the price. The ancilla weight falls from 0.79 to
0.0038 across the sweep. The joint weight of ancilla and clock, which is the rate
that matters on hardware, falls from 0.69 to 0.0033. At n_l = 4, the two differ
by a factor of 1.4 (0.090 against 0.064), so if you quoted success_probability
as the post-selection rate, you would underestimate the number of shots by that factor.
Which matrices are actually cheap
The one classical cost that the "honest" path cannot avoid is the decomposition of
A into Pauli strings, and the number of terms that it finds decides whether a speedup
exists at all. The property that matters is sparsity in the Pauli basis. This is
not the same property as sparsity of the matrix, and confusing the two is the easiest
way to claim too much here. Below, n is the number of state qubits and N = 2ⁿ is
the dimension of A.
- Matrices built as a short Pauli sum have
O(poly n)terms. Examples are local spin Hamiltonians, Ising models anddiag(1…N). These matrices are really cheap. - A matrix that is sparse can be dense in the Pauli basis. A random diagonal matrix
has one non-zero entry per row, and still needs
Nterms. The coefficients of itsZstrings are the Walsh-Hadamard transform of the diagonal, which is dense for a generic diagonal. - The tridiagonal finite-difference matrix is the standard example of a cheap system,
and on a log axis next to
4ⁿit looks like one. It needs exactlyN = 2ⁿterms, which is exponential in the number of qubits. It is cheap compared with a dense matrix, not cheap in absolute terms.
Keep one thing separate. The efficiency assumption of the 2009 HHL paper uses a
different input model. It assumes an A that is s-sparse, whose rows can be
computed efficiently, and that is accessed through a sparse-access oracle. The cost
analysis of that model does not carry over to a decomposition into Pauli terms or into
a linear combination of unitaries. The "honest" path pays the Pauli cost, so that is
the cost that this section measures.
We count the Pauli terms for four families of matrices, across sizes.
def tridiagonal(dim):
return 2.0 * np.eye(dim) - np.eye(dim, k=1) - np.eye(dim, k=-1)
def random_diagonal(dim, seed):
"""One non-zero per row: as matrix-sparse as an invertible matrix can be."""
return np.diag(np.random.default_rng(seed).standard_normal(dim))
def sparse_symmetric(dim, seed):
"""Symmetric with exactly 3 non-zeros per row: a diagonal plus one random cycle."""
r = np.random.default_rng(seed)
A = np.diag(r.standard_normal(dim))
order = r.permutation(dim)
for k in range(dim):
i, j = order[k], order[(k + 1) % dim]
A[i, j] = A[j, i] = r.standard_normal()
return A
def dense_symmetric(dim, seed):
m = np.random.default_rng(seed).standard_normal((dim, dim))
return m + m.T
def dense_hermitian(dim, seed):
r = np.random.default_rng(seed)
m = r.standard_normal((dim, dim)) + 1j * r.standard_normal((dim, dim))
return m + m.conj().T
sizes = [2, 4, 8, 16, 32, 64]
tri_terms, diag_terms, sp3_terms, dense_terms = [], [], [], []
for dim in sizes:
n = dim.bit_length() - 1
sp3 = sparse_symmetric(dim, seed=dim)
tri_terms.append(len(pauli_decompose(tridiagonal(dim))))
diag_terms.append(len(pauli_decompose(random_diagonal(dim, seed=dim))))
sp3_terms.append(len(pauli_decompose(sp3)))
dense_terms.append(len(pauli_decompose(dense_hermitian(dim, seed=dim))))
print(
f"dim={dim:3d} (n={n}): tridiagonal={tri_terms[-1]:4d} "
f"rand-diagonal={diag_terms[-1]:4d} "
f"symmetric({int((sp3 != 0).sum(axis=1).max())} per row)={sp3_terms[-1]:5d} "
f"dense={dense_terms[-1]:5d} (N={dim}, 4^n={4**n})"
)
# Headline, compared like for like: both matrices below are real symmetric, for
# which the Y-odd Pauli strings vanish, so the ceiling is 4^n/2 + N/2 rather
# than 4^n.
dim = 128
sp3_128 = len(pauli_decompose(sparse_symmetric(dim, seed=dim)))
dense_128 = len(pauli_decompose(dense_symmetric(dim, seed=dim)))
print(
f"\nN={dim}: symmetric with 3 non-zeros per row -> {sp3_128} Pauli terms, "
f"against {dense_128} for fully dense real symmetric "
f"({100 * sp3_128 / dense_128:.0f}% of dense)"
)
dim= 2 (n=1): tridiagonal= 2 rand-diagonal= 2 symmetric(2 per row)= 3 dense= 4 (N=2, 4^n=4)
dim= 4 (n=2): tridiagonal= 4 rand-diagonal= 4 symmetric(3 per row)= 8 dense= 16 (N=4, 4^n=16)
dim= 8 (n=3): tridiagonal= 8 rand-diagonal= 8 symmetric(3 per row)= 24 dense= 64 (N=8, 4^n=64)
dim= 16 (n=4): tridiagonal= 16 rand-diagonal= 16 symmetric(3 per row)= 96 dense= 256 (N=16, 4^n=256)
dim= 32 (n=5): tridiagonal= 32 rand-diagonal= 32 symmetric(3 per row)= 368 dense= 1024 (N=32, 4^n=1024)
dim= 64 (n=6): tridiagonal= 64 rand-diagonal= 64 symmetric(3 per row)= 1472 dense= 4096 (N=64, 4^n=4096)
N=128: symmetric with 3 non-zeros per row -> 5440 Pauli terms, against 8256 for fully dense real symmetric (66% of dense)
The figure shows the four families against the two reference curves, 4ⁿ and
N = 2ⁿ:
ns = [d.bit_length() - 1 for d in sizes]
# Plain words plus $...$ mathtext only; see the note above the Trotter figure.
for theme in ("light", "dark"):
with matplotlib.rc_context(rc_params(theme=theme)):
fig, ax = plt.subplots(figsize=(7.5, 4.6))
ax.plot(
ns, dense_terms, "o-", color=STYLE_COLORS[0], lw=2, label="dense Hermitian"
)
ax.plot(
ns,
sp3_terms,
"s-",
color=STYLE_COLORS[1],
lw=2,
label="symmetric, 3 non-zeros per row",
)
ax.plot(ns, tri_terms, "^-", color=STYLE_COLORS[3], lw=2, label="tridiagonal")
ax.plot(
ns, diag_terms, "v--", color=STYLE_COLORS[5], lw=2, label="random diagonal"
)
ax.plot(
ns, [4**n for n in ns], ":", color="0.5", lw=2, label=r"$4^n$ (worst case)"
)
ax.plot(ns, [2**n for n in ns], "-.", color="0.5", lw=1.5, label=r"$N = 2^n$")
ax.set_yscale("log")
ax.set_xlabel(r"state qubits $n = \log_2 N$")
ax.set_ylabel("number of Pauli terms")
ax.set_title("Pauli-basis sparsity is not matrix sparsity")
ax.legend(fontsize=11, loc="upper left")
fig.tight_layout()
fig.savefig(f"scaling_{theme}.png", bbox_inches="tight")
if theme == "light":
plt.show()
else:
plt.close(fig)

The tridiagonal and random diagonal matrices lie exactly on the N = 2ⁿ line, with 2,
4, 8, 16, 32 and 64 terms across the sweep, and only the dense Hermitian matrix follows
4ⁿ. The plot therefore does not show a cheap family and an expensive one. It shows
one family that is exponential with base 2, and one that is exponential with base 4.
On a log axis, this gap looks like a difference in kind, and it is not.
The third curve is the main point of the section. sparse_symmetric is a cycle graph
with its vertices relabelled at random. It has exactly 3 non-zero entries per row,
which is the number that a tridiagonal matrix has in its interior rows. The relabelling
changes nothing about the sparsity of the matrix, and everything about the number of
terms: 1472 terms at N = 64, against 64 for the tridiagonal matrix. This is a factor
of 23, and 36 % of the 4096 terms that a dense Hermitian matrix of that size needs. At
N = 128, it reaches 5440 terms, against 8256 for a fully dense real symmetric matrix.
This is 66 % of a dense matrix, from three non-zero entries per row.
The mechanism is the following. A non-zero entry at (i, j) contributes to the Pauli
strings whose X/Y support is the bit pattern i ⊕ j. The number of terms is
therefore set by how many distinct offsets i ⊕ j the pattern contains, not by how
many non-zero entries it has. A band has only the log₂ N offsets 2ᵏ − 1, which
gives 7 of them at N = 128. This is exactly why a real symmetric tridiagonal matrix
with variable coefficients costs 576 = 64 · 7 + 128 terms. A random pattern uses all
the offsets. For generic coefficients, the number of terms is exactly 2^(n−1) for
each distinct non-zero offset when A is real symmetric, or 2ⁿ when A is complex
Hermitian, plus the number of non-zero Walsh-Hadamard coefficients of the diagonal.
The "honest" path makes this cost visible: it is a number of terms that you can
print before you build a circuit, while a dense expm hides it. The speedup of HHL is
real for a well-conditioned A that is a short Pauli sum, with an efficient way to
prepare b. Sparsity of the matrix on its own provides none of these three
properties.
The cheap family, solved
The first bullet above is the constructive case: a matrix written down as a short
Pauli sum, rather than as an array of numbers. A transverse-field Ising Hamiltonian has
2n terms: the identity shift, a nearest-neighbour ZZ term for each pair of
neighbouring sites, and a local X term on each site. This is linear in the number of
qubits, so the number of terms is no longer the obstacle. Once it is shifted to be
positive definite, it is a linear system like any other, and "honest" mode solves
it.
from eigenkit.algorithms._hamiltonian_sim import pauli_string_matrix
def ising_terms(n, coupling=0.5, field=0.4, shift=3.0):
"""A transverse-field Ising Hamiltonian, shifted positive definite.
Written down as Pauli strings rather than as a matrix, which is what makes it
cheap: nearest-neighbour ZZ plus a local X on each site is 2n terms, linear
in the qubit count, however large N gets.
"""
terms = [(shift, "I" * n)]
for k in range(n - 1):
s = ["I"] * n
s[k] = s[k + 1] = "Z"
terms.append((coupling, "".join(s)))
for k in range(n):
s = ["I"] * n
s[k] = "X"
terms.append((field, "".join(s)))
return terms
for n in (2, 3, 4):
terms_i = ising_terms(n)
A_i = sum(c * pauli_string_matrix(s) for c, s in terms_i).real
b_i = np.zeros(2**n)
b_i[0] = 1.0
print(
f"n={n} (N={2**n:2d}): {len(terms_i)} terms written, "
f"{len(pauli_decompose(A_i))} found by pauli_decompose, "
f"kappa={np.linalg.cond(A_i):.2f}"
)
solve_at_chosen_clock(A_i, b_i)
n=2 (N= 4): 4 terms written, 4 found by pauli_decompose, kappa=1.92
n_l=2: resid=0.2877 cos=0.9005 P_readout=7.80e-01 1 in 1 2 warning(s)
n_l=3: resid=0.0275 cos=0.9997 P_readout=1.70e-01 1 in 6 <- chosen
classical x = [ 0.297 -0.0493 -0.0493 0.0113]
honest x = [ 0.2973 -0.0518 -0.0518 0.0048]
n=3 (N= 8): 6 terms written, 6 found by pauli_decompose, kappa=3.01
n_l=2: resid=0.3071 cos=0.8593 P_readout=7.04e-01 1 in 1 2 warning(s)
n_l=3: resid=0.0436 cos=0.9981 P_readout=1.89e-01 1 in 5
n_l=4: resid=0.0361 cos=0.9994 P_readout=4.14e-02 1 in 24
n_l=5: resid=0.0048 cos=1.0000 P_readout=9.31e-03 1 in 107 <- chosen
classical x = [ 0.2636 -0.0392 -0.0582 0.0136 -0.0392 0.0165 0.0136 -0.0044]
honest x = [ 0.2639 -0.0397 -0.0599 0.0146 -0.0397 0.0174 0.0146 -0.0054]
n=4 (N=16): 8 terms written, 8 found by pauli_decompose, kappa=5.47
n_l=2: resid=0.3213 cos=0.8280 P_readout=6.44e-01 1 in 2 2 warning(s)
n_l=3: resid=0.0805 cos=0.9494 P_readout=2.19e-01 1 in 5 1 warning(s)
n_l=4: resid=0.0228 cos=0.9993 P_readout=4.70e-02 1 in 21 <- chosen
pauli_decompose finds exactly the terms that were written, so nothing is hidden: 8
terms for the 16 × 16 matrix, against the 256 that a dense Hermitian matrix of that
size needs, and the 16 that a random diagonal one needs. The number of terms grows by
two for each added qubit, not by a factor of four.
The three sizes stop at three different registers, with 3, 5 and 4 clock qubits, and
their cosines are 0.9997, 1.0000 and 0.9993. The register at which the selector
stops is therefore not a function of N. It depends on where the eigenvalues of that
system happen to fall on the grid of buckets, which is the subject of the precision
section. The 16 × 16 system stops at n_l = 4, a circuit of 9 qubits, with one
usable preparation in 21. This is the cheapest readout of any system of size 8 × 8
or larger on this page, and it is the largest system.
This last number is the one to plan the number of shots against, and it comes from κ
and the resolution of the clock, not from the Pauli decomposition. Nothing about a
short Pauli sum makes post-selection cheaper. What it makes cheaper is the circuit that
has to be built in the first place.
Summary
"reference" |
"honest" |
|
|---|---|---|
| Eigenvalues | They are found classically with eigvalsh (O(N³)). |
They are not computed. |
Evolution e^{iAt} |
It is a dense black box from expm. |
It is Trotterised into elementary gates. |
Evolution time t |
It is tuned to the known spectrum. | It is fixed from a Gershgorin bound. |
Rotation constant C |
It is the true λ_min. |
It is the resolution of the clock register. |
The evolution e^{iAt} can be built from elementary gates |
❌ No, it is a black-box unitary. | ✅ Yes, it uses elementary gates. |
| Accuracy settings | The only one is n_eigenvalue_qubits. |
They are n_eigenvalue_qubits, plus trotter_steps, trotter_order and trotter_tolerance. |
from eigenkit import HHL
# The system from under the hood, so its Pauli decomposition is on the page.
A = np.array([[2.0, 1.0], [1.0, 3.0]])
b = np.array([1.0, 0.0])
result = HHL(
n_eigenvalue_qubits=4, # clock resolution; the selector above picks this
mode="honest", # hardware-faithful path
trotter_steps="auto", # default: step count from the drift bound
trotter_order=2, # default: symmetric splitting
trotter_tolerance=0.25, # default: drift bound target, as a fraction of C
provider="numpy",
).solve(A, b)
# x ≈ A⁻¹b, read from the statevector; see the read-out caveat below.
print(f"solution = {np.round(result.solution, 4)}")
# ‖Ax − b‖ / ‖b‖, against the original A and b.
print(f"residual = {result.residual:.4f}")
# Weight on ancilla = |1⟩ across all clock states …
print(f"success_probability = {result.success_probability:.4f}")
# … and on ancilla = |1⟩ *and* clock = |0…0⟩, the physical rate: use this one.
print(f"readout_probability = {result.readout_probability:.4f}")
solution = [ 0.6018 -0.2036]
residual = 0.0091
success_probability = 0.1772
readout_probability = 0.1294
The exact answer is [0.6, -0.2], so four clock qubits come within one per cent of
it, at one usable preparation in eight. Four is the register that the selector from
the start of the page chooses for this system. Six clock qubits would reach a residual
of 0.0085 instead of 0.0091, and cost one preparation in 154.
"honest" mode does not change one thing. In both modes, the eigenvalue inversion
still uses a table of 2^n_l − 1 angles, one for each clock bucket. It is built from
elementary gates (the uniformly controlled rotation above), so it costs nothing
asymptotically, because the ladder of controlled evolutions in QPE is already
Θ(2^n_l). However, it means that the classical description of the circuit grows as
Θ(2^n_l), so this is not a uniform family of circuits of polynomial size.
There is also a caveat about the readout. HHL natively produces the quantum state
|x⟩. The full solution vector here is read from the statevector of the simulator.
On real hardware, only expectation values ⟨x|M|x⟩ are available, and reading all N
components would take O(N) measurements. On hardware, HHL is therefore used to pass
|x⟩ to a later quantum routine, or to estimate a single observable.
Next
- MaxCut with QAOA, which uses the quantum approximate optimisation algorithm (QAOA), is the other worked example of an algorithm that reads its answer from a full statevector.
- The HHL chapter explains the two modes, the Trotter settings, and where the speedup does and does not survive.
- The Algorithms guide describes
HHL,IQAEandPortfolioRiskEstimator, and what each result contains.